Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 335
0.00% covered (danger)
0.00%
0 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractSearch
0.00% covered (danger)
0.00%
0 / 335
0.00% covered (danger)
0.00%
0 / 30
10506
0.00% covered (danger)
0.00%
0 / 1
 createViewModel
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 advancedAction
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 redirectToSavedSearch
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 resultScrollerActive
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 rememberSearch
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getActiveRecommendationSettings
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
90
 getSearchSetupCallback
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 homeAction
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 redirectToLegalSearchPage
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 resultsAction
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRssSearchResponse
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 getSearchResultsView
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
240
 processJumpTo
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 processJumpToOnlyResult
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 getRedirectForRecord
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 retrieveSearchSecurely
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 saveSearchToHistory
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 restoreAdvancedSearch
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 getResultsManager
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getRangeSettings
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
56
 getRangeFieldList
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getDateRangeSettings
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getFullDateRangeSettings
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getGenericRangeSettings
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getNumericRangeSettings
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getAllRangeSettings
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
30
 parseSpecialFacetsSetting
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 processAdvancedCheckboxes
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
72
 facetListAction
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 1
56
 getOptionsForClass
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3/**
4 * VuFind Search Controller
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  Controller
25 * @author   Demian Katz <demian.katz@villanova.edu>
26 * @author   Juha Luoma <juha.luoma@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\Controller;
32
33use Exception;
34use Laminas\Http\Response as HttpResponse;
35use Laminas\Session\SessionManager;
36use Laminas\Stdlib\ResponseInterface as Response;
37use Laminas\View\Model\ViewModel;
38use VuFind\Db\Entity\SearchEntityInterface;
39use VuFind\Db\Service\SearchServiceInterface;
40use VuFind\Search\RecommendListener;
41use VuFind\Solr\Utils as SolrUtils;
42
43use function count;
44use function in_array;
45use function intval;
46use function is_array;
47
48/**
49 * VuFind Search Controller
50 *
51 * @category VuFind
52 * @package  Controller
53 * @author   Demian Katz <demian.katz@villanova.edu>
54 * @author   Juha Luoma <juha.luoma@helsinki.fi>
55 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
56 * @link     https://vufind.org Main Page
57 */
58class AbstractSearch extends AbstractBase
59{
60    /**
61     * Search class family to use.
62     *
63     * @var string
64     */
65    protected $searchClassId = 'Solr';
66
67    /**
68     * Should we save searches to history?
69     *
70     * @var bool
71     */
72    protected $saveToHistory = true;
73
74    /**
75     * Should we remember the search for breadcrumb purposes?
76     *
77     * @var bool
78     */
79    protected $rememberSearch = true;
80
81    /**
82     * Create a new ViewModel.
83     *
84     * @param array $params Parameters to pass to ViewModel constructor.
85     *
86     * @return ViewModel
87     */
88    protected function createViewModel($params = null)
89    {
90        $view = parent::createViewModel($params);
91        $view->searchClassId = $this->searchClassId;
92        return $view;
93    }
94
95    /**
96     * Handle an advanced search
97     *
98     * @return ViewModel
99     */
100    public function advancedAction()
101    {
102        $view = $this->createViewModel();
103        $view->options = $this->getOptionsForClass();
104        if ($view->options->getAdvancedSearchAction() === false) {
105            throw new \Exception('Advanced search not supported.');
106        }
107
108        // Handle request to edit existing saved search:
109        $view->saved = false;
110        $searchId = $this->params()->fromQuery('edit', false);
111        if ($searchId !== false) {
112            $view->saved = $this->restoreAdvancedSearch($searchId);
113        }
114
115        // If we have default filters, set them up as a fake "saved" search
116        // to properly populate special controls on the advanced screen.
117        if (!$view->saved && count($view->options->getDefaultFilters()) > 0) {
118            $view->saved = $this->serviceLocator
119                ->get(\VuFind\Search\Results\PluginManager::class)
120                ->get($this->searchClassId);
121            $view->saved->getParams()->initFromRequest(
122                new \Laminas\Stdlib\Parameters([])
123            );
124        }
125
126        return $view;
127    }
128
129    /**
130     * Given a saved search ID, redirect the user to the appropriate place.
131     *
132     * @param int $id ID from search history
133     *
134     * @return mixed
135     */
136    protected function redirectToSavedSearch($id)
137    {
138        $search = $this->retrieveSearchSecurely($id);
139        if (empty($search)) {
140            // User is trying to view a saved search from another session
141            // (deliberate or expired) or associated with another user.
142            throw new \Exception('Attempt to access invalid search ID');
143        }
144
145        // If we got this far, the user is allowed to view the search, so we can
146        // deminify it to a new object.
147        $savedSearch = $search->getSearchObject()?->deminify($this->getResultsManager());
148        if (!$savedSearch) {
149            throw new Exception("Problem getting search object from search {$search->getId()}.");
150        }
151
152        // Now redirect to the URL associated with the saved search; this
153        // simplifies problems caused by mixing different classes of search
154        // object, and it also prevents the user from ever landing on a
155        // "?saved=xxxx" URL, which may not persist beyond the current session.
156        // (We want all searches to be persistent and bookmarkable).
157        $details = $savedSearch->getOptions()->getSearchAction();
158        $url = $this->url()->fromRoute($details);
159        $url .= $savedSearch->getUrlQuery()->getParams(false);
160        return $this->redirect()->toUrl($url);
161    }
162
163    /**
164     * Is the result scroller active?
165     *
166     * @return bool
167     */
168    protected function resultScrollerActive()
169    {
170        // Disabled by default:
171        return false;
172    }
173
174    /**
175     * Store the URL of the provided search (if appropriate).
176     *
177     * @param \VuFind\Search\Base\Results $results Search results object
178     *
179     * @return void
180     */
181    protected function rememberSearch($results)
182    {
183        // Only save search URL if the property tells us to...
184        if ($this->rememberSearch) {
185            $searchUrl = $this->url()->fromRoute(
186                $results->getOptions()->getSearchAction()
187            ) . $results->getUrlQuery()->getParams(false);
188            $this->getSearchMemory()
189                ->rememberSearch($searchUrl, $results->getSearchId());
190        }
191
192        // Always save search parameters, since these are namespaced by search
193        // class ID.
194        $this->getSearchMemory()->rememberParams($results->getParams());
195    }
196
197    /**
198     * Get active recommendation module settings
199     *
200     * @return array
201     */
202    protected function getActiveRecommendationSettings()
203    {
204        // Enable recommendations unless explicitly told to disable them:
205        $all = ['top', 'side', 'noresults', 'bottom'];
206        $noRecommend = $this->params()->fromQuery('noRecommend', false);
207        if (
208            $noRecommend === 1 || $noRecommend === '1'
209            || $noRecommend === 'true' || $noRecommend === true
210        ) {
211            return [];
212        } elseif (
213            $noRecommend === 0 || $noRecommend === '0'
214            || $noRecommend === 'false' || $noRecommend === false
215        ) {
216            return $all;
217        }
218        return array_diff(
219            $all,
220            array_map('trim', explode(',', strtolower($noRecommend)))
221        );
222    }
223
224    /**
225     * Get a callback for setting up a search (or null if callback is unnecessary).
226     *
227     * @return mixed
228     */
229    protected function getSearchSetupCallback()
230    {
231        // Setup callback to attach listener if appropriate:
232        $activeRecs = $this->getActiveRecommendationSettings();
233        if (empty($activeRecs)) {
234            return null;
235        }
236
237        $rManager = $this->serviceLocator
238            ->get(\VuFind\Recommend\PluginManager::class);
239
240        $override = $this->params()->fromQuery('recommendOverride');
241
242        // Retrieve recommend settings from params object:
243        return function ($runner, $params, $searchId) use ($rManager, $activeRecs, $override) {
244            $listener = new RecommendListener($rManager, $searchId);
245            $config = [];
246            $rawConfig = $params->getOptions()
247                ->getRecommendationSettings($params->getSearchHandler());
248            foreach ($rawConfig as $key => $value) {
249                if (in_array($key, $activeRecs)) {
250                    $config[$key] = $value;
251                }
252            }
253
254            // Special case: override recommend settings through parameter (used by
255            // combined search)
256            if (is_array($override)) {
257                $config = array_merge($config, $override);
258            }
259
260            $listener->setConfig($config);
261            $listener->attach($runner->getEventManager()->getSharedManager());
262        };
263    }
264
265    /**
266     * Home action
267     *
268     * @return mixed
269     */
270    public function homeAction()
271    {
272        $blocks = $this->serviceLocator->get(\VuFind\ContentBlock\BlockLoader::class)
273            ->getFromSearchClassId($this->searchClassId);
274        return $this->createViewModel(compact('blocks'));
275    }
276
277    /**
278     * If the search backend has thrown a "deep paging" exception, we should show a
279     * flash message and redirect the user to a legal page.
280     *
281     * @param array $request Incoming request parameters
282     * @param int   $page    Legal page number
283     *
284     * @return mixed
285     */
286    protected function redirectToLegalSearchPage(array $request, int $page)
287    {
288        if (($request['page'] ?? 0) <= $page) {
289            throw new \Exception('Unrecoverable deep paging error.');
290        }
291        $request['page'] = $page;
292        $this->flashMessenger()->addErrorMessage(
293            [
294                'msg' => 'deep_paging_failure',
295                'tokens' => ['%%page%%' => $page],
296            ]
297        );
298        return $this->redirect()->toUrl('?' . http_build_query($request));
299    }
300
301    /**
302     * Send search results to results view
303     *
304     * @return Response|ViewModel
305     */
306    public function resultsAction()
307    {
308        return $this->getSearchResultsView();
309    }
310
311    /**
312     * Support method for getSearchResultsView() -- return the search results
313     * reformatted as an RSS feed.
314     *
315     * @param $view ViewModel View model
316     *
317     * @return Response
318     */
319    protected function getRssSearchResponse(ViewModel $view): Response
320    {
321        // Build the RSS feed:
322        $feedHelper = $this->getViewRenderer()->plugin('resultfeed');
323        $feed = $feedHelper($view->results);
324        $writer = new \Laminas\Feed\Writer\Renderer\Feed\Rss($feed);
325        $writer->render();
326
327        // Apply XSLT if we can find a relevant file:
328        $themeInfo = $this->serviceLocator->get(\VuFindTheme\ThemeInfo::class);
329        $themeHits = $themeInfo->findInThemes('assets/xsl/rss.xsl');
330        if ($themeHits) {
331            $xsl = $this->url()->fromRoute('home') . 'themes/'
332                . $themeHits[0]['theme'] . '/' . $themeHits[0]['relativeFile'];
333            $writer->getElement()->parentNode->insertBefore(
334                $writer->getDomDocument()->createProcessingInstruction(
335                    'xml-stylesheet',
336                    'type="text/xsl" href="' . $xsl . '"'
337                ),
338                $writer->getElement()
339            );
340        }
341
342        // Format the response:
343        $response = $this->getResponse();
344        $response->getHeaders()->addHeaderLine('Content-type', 'text/xml');
345        $response->setContent($writer->saveXml());
346        return $response;
347    }
348
349    /**
350     * Perform a search and send results to a results view
351     *
352     * @param callable $setupCallback Optional setup callback that overrides the
353     * default one
354     *
355     * @return Response|ViewModel
356     */
357    protected function getSearchResultsView($setupCallback = null)
358    {
359        $view = $this->createViewModel();
360
361        // Handle saved search requests:
362        $savedId = $this->params()->fromQuery('saved', false);
363        if ($savedId !== false) {
364            return $this->redirectToSavedSearch($savedId);
365        }
366
367        $runner = $this->serviceLocator->get(\VuFind\Search\SearchRunner::class);
368
369        // Send both GET and POST variables to search class:
370        $request = $this->getRequest()->getQuery()->toArray()
371            + $this->getRequest()->getPost()->toArray();
372        $view->request = $request;
373
374        $lastView = $this->getSearchMemory()
375            ->retrieveLastSetting($this->searchClassId, 'view');
376        try {
377            $view->results = $results = $runner->run(
378                $request,
379                $this->searchClassId,
380                $setupCallback ?: $this->getSearchSetupCallback(),
381                $lastView
382            );
383        } catch (\VuFindSearch\Backend\Exception\DeepPagingException $e) {
384            return $this->redirectToLegalSearchPage($request, $e->getLegalPage());
385        }
386        $view->params = $params = $results->getParams();
387
388        // For page parameter being out of results list, we want to redirect to correct page
389        $page = $params->getPage();
390        $totalResults = $results->getResultTotal();
391        $limit = $params->getLimit();
392        $lastPage = $limit ? ceil($totalResults / $limit) : 1;
393        if ($totalResults > 0 && $page > $lastPage) {
394            $queryParams = $request;
395            $queryParams['page'] = $lastPage;
396            return $this->redirect()->toRoute('search-results', [], [ 'query' => $queryParams ]);
397        }
398
399        // If we received an EmptySet back, that indicates that the real search
400        // failed due to some kind of syntax error, and we should display a
401        // warning to the user; otherwise, we should proceed with normal post-search
402        // processing.
403        if ($results instanceof \VuFind\Search\EmptySet\Results) {
404            $view->parseError = true;
405        } else {
406            // If a "jumpto" parameter is set, deal with that now:
407            if ($jump = $this->processJumpTo($results)) {
408                return $jump;
409            }
410
411            // Remember the current URL as the last search.
412            $this->rememberSearch($results);
413
414            // Add to search history:
415            if ($this->saveToHistory) {
416                $this->saveSearchToHistory($results);
417            }
418
419            // Jump to only result, if configured:
420            if ($jump = $this->processJumpToOnlyResult($results)) {
421                return $jump;
422            }
423
424            // Set up results scroller:
425            if ($this->resultScrollerActive()) {
426                $this->resultScroller()->init($results);
427            }
428
429            foreach ($results->getErrors() as $error) {
430                $this->flashMessenger()->addErrorMessage($error);
431            }
432        }
433
434        // Special case: If we're in RSS view, we need to render differently:
435        if (isset($view->params) && $view->params->getView() == 'rss') {
436            return $this->getRssSearchResponse($view);
437        }
438
439        // Schedule options for footer tools
440        $view->scheduleOptions = $this->serviceLocator
441            ->get(\VuFind\Search\History::class)
442            ->getScheduleOptions();
443        $view->saveToHistory = $this->saveToHistory;
444        return $view;
445    }
446
447    /**
448     * Process the jumpto parameter -- either redirect to a specific record and
449     * return view model, or ignore the parameter and return false.
450     *
451     * @param \VuFind\Search\Base\Results $results Search results object.
452     *
453     * @return bool|HttpResponse
454     */
455    protected function processJumpTo($results)
456    {
457        // Missing/invalid parameter?  Ignore it:
458        $jumpto = $this->params()->fromQuery('jumpto');
459        if (empty($jumpto) || !is_numeric($jumpto)) {
460            return false;
461        }
462
463        $recordList = $results->getResults();
464        return isset($recordList[$jumpto - 1])
465            ? $this->getRedirectForRecord($recordList[$jumpto - 1]) : false;
466    }
467
468    /**
469     * Process jump to record if there is only one result.
470     *
471     * @param \VuFind\Search\Base\Results $results Search results object.
472     *
473     * @return bool|HttpResponse
474     */
475    protected function processJumpToOnlyResult($results)
476    {
477        // If jumpto is explicitly disabled (set to false, e.g. by combined search),
478        // we should NEVER jump to a result regardless of other factors.
479        $jumpto = $this->params()->fromQuery('jumpto', true);
480        if (
481            $jumpto
482            && ($this->getConfig()->Record->jump_to_single_search_result ?? false)
483            && $results->getResultTotal() == 1
484            && $recordList = $results->getResults()
485        ) {
486            return $this->getRedirectForRecord(
487                reset($recordList),
488                ['sid' => $results->getSearchId()]
489            );
490        }
491
492        return false;
493    }
494
495    /**
496     * Get a redirection response to a single record
497     *
498     * @param \VuFind\RecordDriver\AbstractBase $record      Record driver
499     * @param array                             $queryParams Any query parameters
500     *
501     * @return HttpResponse
502     */
503    protected function getRedirectForRecord(
504        \VuFind\RecordDriver\AbstractBase $record,
505        array $queryParams = []
506    ): HttpResponse {
507        $details = $this->getRecordRouter()->getTabRouteDetails($record);
508        return $this->redirect()->toRoute(
509            $details['route'],
510            $details['params'],
511            ['query' => $queryParams]
512        );
513    }
514
515    /**
516     * Get a saved search, enforcing user ownership. Returns row if found, null
517     * otherwise.
518     *
519     * @param int $searchId Primary key value
520     *
521     * @return ?SearchEntityInterface
522     */
523    protected function retrieveSearchSecurely($searchId)
524    {
525        $sessId = $this->serviceLocator->get(SessionManager::class)->getId();
526        return $this->getDbService(SearchServiceInterface::class)
527            ->getSearchByIdAndOwner($searchId, $sessId, $this->getUser());
528    }
529
530    /**
531     * Save a search to the history in the database.
532     *
533     * @param \VuFind\Search\Base\Results $results Search results
534     *
535     * @return void
536     */
537    protected function saveSearchToHistory($results)
538    {
539        $sessId = $this->serviceLocator->get(SessionManager::class)->getId();
540        $this->serviceLocator->get(\VuFind\Search\SearchNormalizer::class)->saveNormalizedSearch(
541            $results,
542            $sessId,
543            $this->getUser()?->getId()
544        );
545    }
546
547    /**
548     * Either assign the requested search object to the view or display a flash
549     * message indicating why the operation failed.
550     *
551     * @param string $searchId ID value of a saved advanced search.
552     *
553     * @return bool|object     Restored search object if found, false otherwise.
554     */
555    protected function restoreAdvancedSearch($searchId)
556    {
557        // Look up search in database and fail if it is not found:
558        $search = $this->retrieveSearchSecurely($searchId);
559        if (empty($search)) {
560            $this->flashMessenger()->addMessage('advSearchError_notFound', 'error');
561            return false;
562        }
563
564        // Restore the full search object:
565        $savedSearch = $search->getSearchObject()?->deminify($this->getResultsManager());
566        if (!$savedSearch) {
567            throw new Exception("Problem getting search object from search {$search->getId()}.");
568        }
569
570        // Fail if this is not the right type of search:
571        if ($savedSearch->getParams()->getSearchType() != 'advanced') {
572            try {
573                $savedSearch->getParams()->convertToAdvancedSearch();
574            } catch (\Exception $ex) {
575                $this->flashMessenger()
576                    ->addMessage('advSearchError_notAdvanced', 'error');
577                return false;
578            }
579        }
580
581        // Make the object available to the view:
582        return $savedSearch;
583    }
584
585    /**
586     * Convenience method for accessing results
587     *
588     * @return \VuFind\Search\Results\PluginManager
589     */
590    protected function getResultsManager()
591    {
592        return $this->serviceLocator
593            ->get(\VuFind\Search\Results\PluginManager::class);
594    }
595
596    /**
597     * Get the current settings for the specified range facet, if it is set:
598     *
599     * @param array  $fields      Fields to check
600     * @param string $type        Type of range to include in return value
601     * @param object $savedSearch Saved search object (false if none)
602     *
603     * @return array
604     */
605    protected function getRangeSettings($fields, $type, $savedSearch = false)
606    {
607        $parts = [];
608
609        foreach ($fields as $field) {
610            // Default to blank strings:
611            $from = $to = '';
612
613            // Check to see if there is an existing range in the search object:
614            if ($savedSearch) {
615                $filters = $savedSearch->getParams()->getRawFilters();
616                foreach ($filters[$field] ?? [] as $current) {
617                    if ($range = SolrUtils::parseRange($current)) {
618                        $from = $range['from'] == '*' ? '' : $range['from'];
619                        $to = $range['to'] == '*' ? '' : $range['to'];
620                        $savedSearch->getParams()
621                            ->removeFilter($field . ':' . $current);
622                        break;
623                    }
624                }
625            }
626
627            // Send back the settings:
628            $parts[] = [
629                'field' => $field,
630                'type' => $type,
631                'values' => [$from, $to],
632            ];
633        }
634
635        return $parts;
636    }
637
638    /**
639     * Get the range facet configurations from the specified config section and
640     * filter them appropriately.
641     *
642     * @param string $config  Name of config file
643     * @param string $section Configuration section to check
644     * @param array  $filter  List of fields to include (if empty, all
645     * fields will be returned)
646     *
647     * @return array
648     */
649    protected function getRangeFieldList($config, $section, $filter)
650    {
651        $config = $this->serviceLocator->get(\VuFind\Config\PluginManager::class)
652            ->get($config);
653        $fields = isset($config->SpecialFacets->$section)
654            ? $config->SpecialFacets->$section->toArray() : [];
655
656        if (!empty($filter)) {
657            $fields = array_intersect($fields, $filter);
658        }
659
660        return $fields;
661    }
662
663    /**
664     * Get the current settings for the date range facets, if set:
665     *
666     * @param object $savedSearch Saved search object (false if none)
667     * @param string $config      Name of config file
668     * @param array  $filter      List of fields to include (if empty, all
669     * fields will be returned)
670     *
671     * @return array
672     */
673    protected function getDateRangeSettings(
674        $savedSearch = false,
675        $config = 'facets',
676        $filter = []
677    ) {
678        $fields = $this->getRangeFieldList($config, 'dateRange', $filter);
679        return $this->getRangeSettings($fields, 'date', $savedSearch);
680    }
681
682    /**
683     * Get the current settings for the full date range facets, if set:
684     *
685     * @param object $savedSearch Saved search object (false if none)
686     * @param string $config      Name of config file
687     * @param array  $filter      List of fields to include (if empty, all
688     * fields will be returned)
689     *
690     * @return array
691     */
692    protected function getFullDateRangeSettings(
693        $savedSearch = false,
694        $config = 'facets',
695        $filter = []
696    ) {
697        $fields = $this->getRangeFieldList($config, 'fullDateRange', $filter);
698        return $this->getRangeSettings($fields, 'fulldate', $savedSearch);
699    }
700
701    /**
702     * Get the current settings for the generic range facets, if set:
703     *
704     * @param object $savedSearch Saved search object (false if none)
705     * @param string $config      Name of config file
706     * @param array  $filter      List of fields to include (if empty, all
707     * fields will be returned)
708     *
709     * @return array
710     */
711    protected function getGenericRangeSettings(
712        $savedSearch = false,
713        $config = 'facets',
714        $filter = []
715    ) {
716        $fields = $this->getRangeFieldList($config, 'genericRange', $filter);
717        return $this->getRangeSettings($fields, 'generic', $savedSearch);
718    }
719
720    /**
721     * Get the current settings for the numeric range facets, if set:
722     *
723     * @param object $savedSearch Saved search object (false if none)
724     * @param string $config      Name of config file
725     * @param array  $filter      List of fields to include (if empty, all
726     * fields will be returned)
727     *
728     * @return array
729     */
730    protected function getNumericRangeSettings(
731        $savedSearch = false,
732        $config = 'facets',
733        $filter = []
734    ) {
735        $fields = $this->getRangeFieldList($config, 'numericRange', $filter);
736        return $this->getRangeSettings($fields, 'numeric', $savedSearch);
737    }
738
739    /**
740     * Get all active range facets:
741     *
742     * @param array  $specialFacets Special facet setting (in parsed format)
743     * @param object $savedSearch   Saved search object (false if none)
744     * @param string $config        Name of config file
745     *
746     * @return array
747     */
748    protected function getAllRangeSettings(
749        $specialFacets,
750        $savedSearch = false,
751        $config = 'facets'
752    ) {
753        $result = [];
754        if (isset($specialFacets['daterange'])) {
755            $dates = $this->getDateRangeSettings(
756                $savedSearch,
757                $config,
758                $specialFacets['daterange']
759            );
760            $result = array_merge($result, $dates);
761        }
762        if (isset($specialFacets['fulldaterange'])) {
763            $fulldates = $this->getFullDateRangeSettings(
764                $savedSearch,
765                $config,
766                $specialFacets['fulldaterange']
767            );
768            $result = array_merge($result, $fulldates);
769        }
770        if (isset($specialFacets['genericrange'])) {
771            $generic = $this->getGenericRangeSettings(
772                $savedSearch,
773                $config,
774                $specialFacets['genericrange']
775            );
776            $result = array_merge($result, $generic);
777        }
778        if (isset($specialFacets['numericrange'])) {
779            $numeric = $this->getNumericRangeSettings(
780                $savedSearch,
781                $config,
782                $specialFacets['numericrange']
783            );
784            $result = array_merge($result, $numeric);
785        }
786        return $result;
787    }
788
789    /**
790     * Parse the "special facets" setting.
791     *
792     * @param string $specialFacets Unparsed string
793     *
794     * @return array
795     */
796    protected function parseSpecialFacetsSetting($specialFacets)
797    {
798        // Parse the special facets into a more useful format:
799        $parsed = [];
800        foreach (explode(',', $specialFacets) as $current) {
801            $parts = explode(':', $current);
802            $key = array_shift($parts);
803            $parsed[$key] = $parts;
804        }
805        return $parsed;
806    }
807
808    /**
809     * Process the checkbox setting from special facets.
810     *
811     * @param array  $params      Parameters to the checkbox setting
812     * @param object $savedSearch Saved search object (false if none)
813     *
814     * @return array
815     */
816    protected function processAdvancedCheckboxes($params, $savedSearch = false)
817    {
818        // Set defaults for missing parameters:
819        $config = $params[0] ?? 'facets';
820        $section = $params[1] ?? 'CheckboxFacets';
821
822        // Load config file:
823        $config = $this->serviceLocator->get(\VuFind\Config\PluginManager::class)
824            ->get($config);
825
826        // Process checkbox settings in config:
827        $flipCheckboxes = false;
828        if (str_starts_with($section, '~')) {        // reverse flag
829            $section = substr($section, 1);
830            $flipCheckboxes = true;
831        }
832        $checkboxFacets = ($section && isset($config->$section))
833            ? $config->$section->toArray() : [];
834        if ($flipCheckboxes) {
835            $checkboxFacets = array_flip($checkboxFacets);
836        }
837
838        // Reformat for convenience:
839        $formatted = [];
840        foreach ($checkboxFacets as $filter => $desc) {
841            $current = compact('desc', 'filter');
842            $current['selected']
843                = $savedSearch && $savedSearch->getParams()->hasFilter($filter);
844            // We don't want to double-display checkboxes on advanced search, so
845            // if they are checked, we should remove them from the object to
846            // prevent display in the "other filters" area.
847            if ($current['selected']) {
848                $savedSearch->getParams()->removeFilter($filter);
849            }
850            $formatted[] = $current;
851        }
852
853        return $formatted;
854    }
855
856    /**
857     * Returns a list of all items associated with one facet for the lightbox
858     *
859     * Parameters:
860     * facet        The facet to retrieve
861     * searchParams Facet search params from $results->getUrlQuery()->getParams()
862     *
863     * @return mixed
864     */
865    public function facetListAction()
866    {
867        $this->disableSessionWrites();  // avoid session write timing bug
868        // Get results
869        $results = $this->getResultsManager()->get($this->searchClassId);
870        $params = $results->getParams();
871        $params->initFromRequest($this->getRequest()->getQuery());
872        // Get parameters
873        $facet = $this->params()->fromQuery('facet');
874        $contains = $this->params()->fromQuery('contains', '');
875        $page = (int)$this->params()->fromQuery('facetpage', 1);
876        // Has the request been sent in an AJAX context?
877        $ajax = (int)$this->params()->fromQuery('ajax', 0);
878        $urlBase = $this->params()->fromQuery('urlBase', '');
879        $searchAction = $this->params()->fromQuery('searchAction', '');
880        // $urlBase and $searchAction should be relative URLs; if there is an
881        // absolute URL passed in, this may be a sign of malicious activity and
882        // we should fail.
883        if (str_contains($urlBase . $searchAction, '://')) {
884            throw new \Exception('Unexpected absolute URL found.');
885        }
886        $options = $results->getOptions();
887        $facetSortOptions = $options->getFacetSortOptions($facet);
888        $sort = $this->params()->fromQuery('facetsort', null);
889        if ($sort === null || !in_array($sort, array_keys($facetSortOptions))) {
890            $sort = empty($facetSortOptions)
891                ? 'count'
892                : current(array_keys($facetSortOptions));
893        }
894        $config = $this->serviceLocator->get(\VuFind\Config\PluginManager::class)
895            ->get($options->getFacetsIni());
896        $limit = $config->Results_Settings->lightboxLimit ?? 50;
897        $limit = $this->params()->fromQuery('facetlimit', $limit);
898        if (!empty($contains)) {
899            $params->setFacetContains($contains);
900            $params->setFacetContainsIgnoreCase(true);
901        }
902        $facets = $results->getPartialFieldFacets(
903            [$facet],
904            false,
905            $limit,
906            $sort,
907            $page,
908            $this->params()->fromQuery('facetop', 'AND') == 'OR'
909        );
910        $list = $facets[$facet]['data']['list'] ?? [];
911        $facetLabel = $params->getFacetLabel($facet);
912
913        $viewParams = [
914            'contains' => $contains,
915            'data' => $list,
916            'exclude' => intval($this->params()->fromQuery('facetexclude', 0)),
917            'facet' => $facet,
918            'facetLabel' => $facetLabel,
919            'operator' => $this->params()->fromQuery('facetop', 'AND'),
920            'page' => $page,
921            'results' => $results,
922            'anotherPage' => $facets[$facet]['more'] ?? '',
923            'sort' => $sort,
924            'sortOptions' => $facetSortOptions,
925            'baseUriExtra' => $this->params()->fromQuery('baseUriExtra'),
926            'active' => $sort,
927            'key' => $sort,
928            'urlBase' => $urlBase,
929            'searchAction' => $searchAction,
930        ];
931        $viewParams['delegateParams'] = $viewParams;
932        $view = $this->createViewModel($viewParams);
933        $view->setTemplate($ajax ? 'search/facet-list-content' : 'search/facet-list');
934        return $view;
935    }
936
937    /**
938     * Get proper options file for search class
939     *
940     * @return \VuFind\Search\Base\Options
941     */
942    public function getOptionsForClass(): \VuFind\Search\Base\Options
943    {
944        return $this->serviceLocator
945            ->get(\VuFind\Search\Options\PluginManager::class)
946            ->get($this->searchClassId);
947    }
948}