Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 157
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
CombinedController
0.00% covered (danger)
0.00%
0 / 157
0.00% covered (danger)
0.00%
0 / 6
1332
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 homeAction
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 resultAction
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
20
 resultsAction
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 1
132
 searchboxAction
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
72
 adjustQueryForSettings
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
132
1<?php
2
3/**
4 * Combined Search Controller
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2010.
9 * Copyright (C) The National Library of Finland 2024.
10 *
11 * This program is free software; you can redistribute it and/or modify
12 * it under the terms of the GNU General Public License version 2,
13 * as published by the Free Software Foundation.
14 *
15 * This program is distributed in the hope that it will be useful,
16 * but WITHOUT ANY WARRANTY; without even the implied warranty of
17 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18 * GNU General Public License for more details.
19 *
20 * You should have received a copy of the GNU General Public License
21 * along with this program; if not, write to the Free Software
22 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
23 *
24 * @category VuFind
25 * @package  Controller
26 * @author   Demian Katz <demian.katz@villanova.edu>
27 * @author   Ere Maijala <ere.maijala@helsinki.fi>
28 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
29 * @link     https://vufind.org Main Site
30 */
31
32namespace VuFind\Controller;
33
34use Laminas\ServiceManager\ServiceLocatorInterface;
35use VuFind\Search\SearchRunner;
36
37use function count;
38use function in_array;
39use function intval;
40use function is_array;
41
42/**
43 * Redirects the user to the appropriate default VuFind action.
44 *
45 * @category VuFind
46 * @package  Controller
47 * @author   Demian Katz <demian.katz@villanova.edu>
48 * @author   Ere Maijala <ere.maijala@helsinki.fi>
49 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
50 * @link     https://vufind.org Main Site
51 */
52class CombinedController extends AbstractSearch
53{
54    use AjaxResponseTrait;
55
56    /**
57     * Constructor
58     *
59     * @param ServiceLocatorInterface $sm Service locator
60     */
61    public function __construct(ServiceLocatorInterface $sm)
62    {
63        $this->searchClassId = 'Combined';
64        parent::__construct($sm);
65    }
66
67    /**
68     * Home action
69     *
70     * @return mixed
71     */
72    public function homeAction()
73    {
74        // We need to load blocks differently in this controller since it
75        // doesn't follow the usual configuration pattern.
76        $blocks = $this->getService(\VuFind\ContentBlock\BlockLoader::class)
77            ->getFromConfig('combined');
78        return $this->createViewModel(compact('blocks'));
79    }
80
81    /**
82     * Single result action (used for AJAX)
83     *
84     * @return mixed
85     */
86    public function resultAction()
87    {
88        $this->disableSessionWrites();  // avoid session write timing bug
89
90        // Turn off search memory -- not relevant in this context:
91        $this->getSearchMemory()->disable();
92
93        // Validate configuration:
94        $sectionId = $this->params()->fromQuery('id');
95        $optionsManager = $this->getService(\VuFind\Search\Options\PluginManager::class);
96        $combinedOptions = $optionsManager->get('combined');
97        $tabConfig = $combinedOptions->getTabConfig();
98        if (!isset($tabConfig[$sectionId])) {
99            throw new \Exception('Illegal ID');
100        }
101        [$searchClassId] = explode(':', $sectionId);
102
103        // Retrieve results:
104        $currentOptions = $optionsManager->get($searchClassId);
105        [$controller, $action]
106            = explode('-', $currentOptions->getSearchAction());
107        $settings = $tabConfig[$sectionId];
108
109        $this->adjustQueryForSettings(
110            $settings,
111            $currentOptions->getHandlerForLabel($this->params()->fromQuery('type'))
112        );
113        $settings['view'] = $this->forwardTo($controller, $action);
114
115        // Should we suppress content due to emptiness?
116        if (
117            ($settings['hide_if_empty'] ?? false)
118            && $settings['view']->results->getResultTotal() <= 0
119        ) {
120            $html = '';
121        } else {
122            $viewParams = [
123                'searchClassId' => $searchClassId,
124                'currentSearch' => $settings,
125                'domId' => 'combined_' . str_replace(':', '____', $sectionId),
126            ];
127            // Initialize theme resources:
128            ($this->getViewRenderer()->plugin('setupThemeResources'))(true);
129            // Render content:
130            $html = $this->getViewRenderer()->render(
131                'combined/results-list.phtml',
132                $viewParams
133            );
134            // Prepend CSS in case of custom files added by templates:
135            $html = ($this->getViewRenderer()->plugin('headLink'))() . $html;
136        }
137        return $this->getAjaxResponse('text/html', $html);
138    }
139
140    /**
141     * Results action
142     *
143     * @return mixed
144     */
145    public function resultsAction()
146    {
147        // Set up current request context:
148        $request = $this->getRequest()->getQuery()->toArray()
149            + $this->getRequest()->getPost()->toArray();
150        $results = $this->getService(SearchRunner::class)->run(
151            $request,
152            'Combined',
153            $this->getSearchSetupCallback()
154        );
155
156        // Remember the current URL, then disable memory so multi-search results
157        // don't overwrite it:
158        $this->rememberSearch($results);
159        $this->getSearchMemory()->disable();
160
161        // Gather combined results:
162        $combinedResults = [];
163        $optionsManager = $this->getService(\VuFind\Search\Options\PluginManager::class);
164        $combinedOptions = $optionsManager->get('combined');
165        // Save the initial type value, since it may get manipulated below:
166        $initialType = $this->params()->fromQuery('type');
167        foreach ($combinedOptions->getTabConfig() as $current => $settings) {
168            [$searchClassId] = explode(':', $current);
169            $currentOptions = $optionsManager->get($searchClassId);
170            $this->adjustQueryForSettings(
171                $settings,
172                $currentOptions->getHandlerForLabel($initialType)
173            );
174            [$controller, $action]
175                = explode('-', $currentOptions->getSearchAction());
176            $combinedResults[$current] = $settings;
177
178            // Calculate a unique DOM id for this section of the search results;
179            // $searchClassId may contain colons, which must be converted.
180            $combinedResults[$current]['domId']
181                = 'combined_' . str_replace(':', '____', $current);
182
183            $permissionDenied = isset($settings['permission'])
184                && !$this->permission()->isAuthorized($settings['permission']);
185            $isAjax = $settings['ajax'] ?? false;
186            $combinedResults[$current]['view'] = ($permissionDenied || $isAjax)
187                ? $this->createViewModel(['results' => $results])
188                : $this->forwardTo($controller, $action);
189
190            // Special case: include appropriate "powered by" message:
191            if (strtolower($searchClassId) == 'summon') {
192                $this->layout()->poweredBy = 'Powered by Summonâ„¢ from Serials '
193                    . 'Solutions, a division of ProQuest.';
194            }
195        }
196
197        // Restore the initial type value to the query to prevent weird behavior:
198        $this->getRequest()->getQuery()->type = $initialType;
199
200        // Run the search to obtain recommendations:
201        $results->performAndProcessSearch();
202
203        $actualMaxColumns = count($combinedResults);
204        $config = $this->getService(\VuFind\Config\PluginManager::class)->get('combined')->toArray();
205        $columnConfig = intval($config['Layout']['columns'] ?? $actualMaxColumns);
206        $columns = min($columnConfig, $actualMaxColumns);
207        $placement = $config['Layout']['stack_placement'] ?? 'distributed';
208        if (!in_array($placement, ['distributed', 'left', 'right', 'grid'])) {
209            $placement = 'distributed';
210        }
211
212        // Identify if any modules use include_recommendations_side or
213        // include_recommendations_noresults_side.
214        $columnSideRecommendations = [];
215        $recommendationManager = $this->getService(\VuFind\Recommend\PluginManager::class);
216        foreach ($config as $subconfig) {
217            foreach (['include_recommendations_side', 'include_recommendations_noresults_side'] as $type) {
218                if (is_array($subconfig[$type] ?? false)) {
219                    foreach ($subconfig[$type] as $recommendation) {
220                        $recommendationModuleName = strtok($recommendation, ':');
221                        $recommendationModule = $recommendationManager->get($recommendationModuleName);
222                        $columnSideRecommendations[] = str_replace('\\', '_', $recommendationModule::class);
223                    }
224                }
225            }
226        }
227
228        // Build view model:
229        return $this->createViewModel(
230            [
231                'columns' => $columns,
232                'combinedResults' => $combinedResults,
233                'config' => $config,
234                'params' => $results->getParams(),
235                'placement' => $placement,
236                'results' => $results,
237                'columnSideRecommendations' => $columnSideRecommendations,
238            ]
239        );
240    }
241
242    /**
243     * Action to process the combined search box.
244     *
245     * @return mixed
246     */
247    public function searchboxAction()
248    {
249        [$type, $target] = explode(':', $this->params()->fromQuery('type'), 2);
250        switch ($type) {
251            case 'VuFind':
252                [$searchClassId, $type] = explode('|', $target);
253                $params = $this->getRequest()->getQuery()->toArray();
254                $params['type'] = $type;
255
256                // Disable retained filters if we are switching classes!
257                $activeClass = $this->params()->fromQuery('activeSearchClassId');
258                if ($activeClass != $searchClassId) {
259                    unset($params['filter']);
260                }
261                // We don't need to pass activeSearchClassId forward:
262                unset($params['activeSearchClassId']);
263
264                $route = $this->getService(\VuFind\Search\Options\PluginManager::class)
265                    ->get($searchClassId)->getSearchAction();
266                $base = $this->url()->fromRoute($route);
267                return $this->redirect()
268                    ->toUrl($base . '?' . http_build_query($params));
269            case 'External':
270                $lookfor = $this->params()->fromQuery('lookfor');
271                $finalTarget = (!str_contains($target, '%%lookfor%%'))
272                    ? $target . urlencode($lookfor)
273                    : str_replace('%%lookfor%%', urlencode($lookfor), $target);
274                return $this->redirect()->toUrl($finalTarget);
275            default:
276                // If parameters are completely missing, redirect to home instead
277                // of throwing an error; this is possibly a misbehaving crawler that
278                // followed the SearchBox URL without passing any parameters.
279                if (empty($type) && empty($target)) {
280                    return $this->redirect()->toRoute('home');
281                }
282                // If we have a weird value here, report it as an Exception:
283                throw new \VuFind\Exception\BadRequest(
284                    'Unexpected search type: "' . $type . '".'
285                );
286        }
287    }
288
289    /**
290     * Adjust the query context to reflect the current settings.
291     *
292     * @param array  $settings   Settings
293     * @param string $searchType Override for search handler name
294     *
295     * @return void
296     */
297    protected function adjustQueryForSettings($settings, $searchType = null)
298    {
299        // Apply limit setting, if any:
300        $query = $this->getRequest()->getQuery();
301        $query->limit = $settings['limit'] ?? null;
302
303        // Apply filters, if any:
304        $query->filter = isset($settings['filter'])
305            ? (array)$settings['filter'] : null;
306
307        // Apply hidden filters, if any:
308        $query->hiddenFilters = isset($settings['hiddenFilter'])
309            ? (array)$settings['hiddenFilter'] : null;
310
311        // Apply shards, if any:
312        $query->shard = isset($settings['shard'])
313            ? (array)$settings['shard'] : null;
314
315        // Reset override to avoid bleed-over from one section to the next!
316        $query->recommendOverride = false;
317
318        // Always disable 'jumpto' setting, as it does not make sense to
319        // load a record view in the context of combined search.
320        $query->jumpto = false;
321
322        // Override the search type:
323        $query->type = $searchType;
324
325        // Display or hide top based on include_recommendations setting.
326        $recommendOverride = [];
327        $noRecommend = [];
328        $includeRecommendSetting = $settings['include_recommendations'] ?? false;
329        if (is_array($includeRecommendSetting)) {
330            $recommendOverride['top'] = $settings['include_recommendations'];
331        } elseif (!$includeRecommendSetting) {
332            $noRecommend[] = 'top';
333        }
334
335        // Display or hide side based on include_recommendations_side setting.
336        if (is_array($settings['include_recommendations_side'] ?? false)) {
337            $recommendOverride['side'] = $settings['include_recommendations_side'];
338        } else {
339            $noRecommend[] = 'side';
340        }
341
342        // Display or hide no results recommendations, based on
343        // include_recommendations_noresults setting (to display them in the bento box) or
344        // include_recommendations_noresults_side setting (to display them in the sidebar).
345        $includeRecommendNoResultsSetting = $settings['include_recommendations_noresults'] ?? false;
346        if (is_array($includeRecommendNoResultsSetting)) {
347            $recommendOverride['noresults'] = $settings['include_recommendations_noresults'];
348        } elseif (!$includeRecommendNoResultsSetting) {
349            $noRecommend[] = 'noresults';
350        }
351
352        if (is_array($settings['include_recommendations_noresults_side'] ?? false)) {
353            $recommendOverride['noresults_side'] = $settings['include_recommendations_noresults_side'];
354        } else {
355            $noRecommend[] = 'noresults_side';
356        }
357
358        $query->recommendOverride = $recommendOverride;
359        $query->noRecommend = count($noRecommend) ? implode(',', $noRecommend) : false;
360    }
361}