Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 383
0.00% covered (danger)
0.00%
0 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractRecord
0.00% covered (danger)
0.00%
0 / 383
0.00% covered (danger)
0.00%
0 / 27
11772
0.00% covered (danger)
0.00%
0 / 1
 createViewModel
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 addcommentAction
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
90
 deletecommentAction
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 addtagAction
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 deletetagAction
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 ratingAction
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
90
 homeAction
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
56
 ajaxtabAction
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 processSave
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 saveAction
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
156
 emailAction
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
72
 smsEnabled
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 smsAction
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
30
 citeAction
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 permalinkAction
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 exportAction
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
72
 rdfAction
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 explainAction
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 loadRecord
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 redirectToRecord
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 loadTabDetails
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 getDefaultTab
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getAllTabs
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getBackgroundTabs
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getTabsExtraScripts
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 resultScrollerActive
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 showTab
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
72
1<?php
2
3/**
4 * VuFind Record Controller
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2010-2024.
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 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
28 */
29
30namespace VuFind\Controller;
31
32use VuFind\Db\Service\UserListServiceInterface;
33use VuFind\Db\Service\UserResourceServiceInterface;
34use VuFind\Exception\BadRequest as BadRequestException;
35use VuFind\Exception\Forbidden as ForbiddenException;
36use VuFind\Exception\Mail as MailException;
37use VuFind\Ratings\RatingsService;
38use VuFind\Record\ResourcePopulator;
39use VuFind\RecordDriver\AbstractBase as AbstractRecordDriver;
40use VuFind\Tags\TagsService;
41use VuFindSearch\ParamBag;
42
43use function in_array;
44use function intval;
45use function is_array;
46use function is_object;
47
48/**
49 * VuFind Record Controller
50 *
51 * @category VuFind
52 * @package  Controller
53 * @author   Chris Hallberg <challber@villanova.edu>
54 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
55 * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
56 */
57class AbstractRecord extends AbstractBase
58{
59    /**
60     * Array of available tab options
61     *
62     * @var array
63     */
64    protected $allTabs = null;
65
66    /**
67     * Default tab to display (configured at record driver level)
68     *
69     * @var string
70     */
71    protected $defaultTab = null;
72
73    /**
74     * Default tab to display (fallback used if no record driver configuration)
75     *
76     * @var string
77     */
78    protected $fallbackDefaultTab = 'Holdings';
79
80    /**
81     * Array of background tabs
82     *
83     * @var array
84     */
85    protected $backgroundTabs = null;
86
87    /**
88     * Array of extra scripts for tabs
89     *
90     * @var array
91     */
92    protected $tabsExtraScripts = null;
93
94    /**
95     * Type of record to display
96     *
97     * @var string
98     */
99    protected $sourceId = 'Solr';
100
101    /**
102     * Record driver
103     *
104     * @var AbstractRecordDriver
105     */
106    protected $driver = null;
107
108    /**
109     * Create a new ViewModel.
110     *
111     * @param array $params Parameters to pass to ViewModel constructor.
112     *
113     * @return \Laminas\View\Model\ViewModel
114     */
115    protected function createViewModel($params = null)
116    {
117        $view = parent::createViewModel($params);
118        $view->driver = $this->loadRecord();
119        $this->layout()->searchClassId = $view->searchClassId
120            = $view->driver->getSearchBackendIdentifier();
121        return $view;
122    }
123
124    /**
125     * Add a comment
126     *
127     * @return mixed
128     */
129    public function addcommentAction()
130    {
131        // Make sure comments are enabled:
132        if (!$this->commentsEnabled()) {
133            throw new ForbiddenException('Comments disabled');
134        }
135
136        $captchaActive = $this->captcha()->active('userComments');
137
138        // Force login:
139        if (!($user = $this->getUser())) {
140            // Validate CAPTCHA before redirecting to login:
141            if (!$this->formWasSubmitted('comment', $captchaActive)) {
142                return $this->redirectToRecord('', 'UserComments');
143            }
144
145            // Remember comment since POST data will be lost:
146            return $this->forceLogin(
147                null,
148                ['comment' => $this->params()->fromPost('comment')]
149            );
150        }
151
152        // Obtain the current record object:
153        $driver = $this->loadRecord();
154
155        // Save comment:
156        $comment = $this->params()->fromPost('comment');
157        if (empty($comment)) {
158            $comment = $this->followup()->retrieveAndClear('comment');
159        } else {
160            // Validate CAPTCHA now only if we're not coming back post-login:
161            if (!$this->formWasSubmitted('comment', $captchaActive)) {
162                return $this->redirectToRecord('', 'UserComments');
163            }
164        }
165
166        // At this point, we should have a comment to save; if we do not,
167        // something has gone wrong (or user submitted blank form) and we
168        // should do nothing:
169        if (!empty($comment)) {
170            $populator = $this->serviceLocator->get(ResourcePopulator::class);
171            $resource = $populator->getOrCreateResourceForDriver($driver);
172            $commentsService = $this->getDbService(
173                \VuFind\Db\Service\CommentsServiceInterface::class
174            );
175            $commentsService->addComment($comment, $user, $resource);
176
177            // Save rating if allowed:
178            if (
179                $driver->isRatingAllowed()
180                && '0' !== ($rating = $this->params()->fromPost('rating', '0'))
181            ) {
182                $ratingsService = $this->serviceLocator->get(RatingsService::class);
183                $ratingsService->saveRating($driver, $user->getId(), intval($rating));
184            }
185
186            $this->flashMessenger()->addMessage('add_comment_success', 'success');
187        } else {
188            $this->flashMessenger()->addMessage('add_comment_fail_blank', 'error');
189        }
190
191        return $this->redirectToRecord('', 'UserComments');
192    }
193
194    /**
195     * Delete a comment
196     *
197     * @return mixed
198     */
199    public function deletecommentAction()
200    {
201        // Make sure comments are enabled:
202        if (!$this->commentsEnabled()) {
203            throw new ForbiddenException('Comments disabled');
204        }
205
206        // Force login:
207        if (!($user = $this->getUser())) {
208            return $this->forceLogin();
209        }
210        $id = $this->params()->fromQuery('delete');
211        $commentsService = $this->getDbService(
212            \VuFind\Db\Service\CommentsServiceInterface::class
213        );
214        if (null !== $id && $commentsService->deleteIfOwnedByUser($id, $user)) {
215            $this->flashMessenger()->addMessage('delete_comment_success', 'success');
216        } else {
217            $this->flashMessenger()->addMessage('delete_comment_failure', 'error');
218        }
219        return $this->redirectToRecord('', 'UserComments');
220    }
221
222    /**
223     * Add a tag
224     *
225     * @return mixed
226     */
227    public function addtagAction()
228    {
229        // Make sure tags are enabled:
230        if (!$this->tagsEnabled()) {
231            throw new ForbiddenException('Tags disabled');
232        }
233
234        // Force login:
235        if (!($user = $this->getUser())) {
236            return $this->forceLogin();
237        }
238
239        // Obtain the current record object:
240        $driver = $this->loadRecord();
241
242        // Save tags, if any:
243        if ($tags = $this->params()->fromPost('tag')) {
244            $this->serviceLocator->get(TagsService::class)->linkTagsToRecord($driver, $user, $tags);
245            $this->flashMessenger()->addMessage(['msg' => 'add_tag_success'], 'success');
246            return $this->redirectToRecord();
247        }
248
249        // Display the "add tag" form:
250        $view = $this->createViewModel();
251        $view->setTemplate('record/addtag');
252        return $view;
253    }
254
255    /**
256     * Delete a tag
257     *
258     * @return mixed
259     */
260    public function deletetagAction()
261    {
262        // Make sure tags are enabled:
263        if (!$this->tagsEnabled()) {
264            throw new ForbiddenException('Tags disabled');
265        }
266
267        // Force login:
268        if (!($user = $this->getUser())) {
269            return $this->forceLogin();
270        }
271
272        // Obtain the current record object:
273        $driver = $this->loadRecord();
274
275        // Delete tags, if any:
276        if ($tag = $this->params()->fromPost('tag')) {
277            $this->serviceLocator->get(TagsService::class)->unlinkTagsFromRecord(
278                $driver,
279                $user,
280                [$tag]
281            );
282            $this->flashMessenger()->addMessage(
283                [
284                    'msg' => 'tags_deleted',
285                    'tokens' => ['%count%' => 1],
286                ],
287                'success'
288            );
289        }
290
291        return $this->redirectToRecord();
292    }
293
294    /**
295     * Display and add ratings
296     *
297     * @return mixed
298     */
299    public function ratingAction()
300    {
301        // Obtain the current record object:
302        $driver = $this->loadRecord();
303
304        // Make sure ratings are allowed for the record:
305        if (!$driver->isRatingAllowed()) {
306            throw new ForbiddenException('rating_disabled');
307        }
308
309        // Save rating, if any, and user has logged in:
310        $user = $this->getUser();
311        if ($user && null !== ($rating = $this->params()->fromPost('rating'))) {
312            if (
313                '' === $rating
314                && !($this->getConfig()->Social->remove_rating ?? true)
315            ) {
316                throw new BadRequestException('error_inconsistent_parameters');
317            }
318            $ratingsService = $this->serviceLocator->get(RatingsService::class);
319            $ratingsService->saveRating(
320                $driver,
321                $user->getId(),
322                '' === $rating ? null : intval($rating)
323            );
324            $this->flashMessenger()->addSuccessMessage('rating_add_success');
325            if ($this->inLightbox()) {
326                return $this->getRefreshResponse();
327            }
328            return $this->redirectToRecord();
329        }
330
331        // Display the "add rating" form:
332        $currentRating = $user
333            ? $this->serviceLocator->get(RatingsService::class)->getRatingData($driver, $user->getId())
334            : null;
335        return $this->createViewModel(compact('currentRating'));
336    }
337
338    /**
339     * Home (default) action -- forward to requested (or default) tab.
340     *
341     * @return mixed
342     */
343    public function homeAction()
344    {
345        // If collections are active, we may need to check if the driver is actually
346        // a collection; if so, we should redirect to the collection controller.
347        $checkRoute = $this->params()->fromPost('checkRoute')
348            ?? $this->params()->fromQuery('checkRoute')
349            ?? false;
350        $config = $this->getConfig();
351        if ($checkRoute && $config->Collections->collections ?? false) {
352            $routeConfig = isset($config->Collections->route)
353                ? $config->Collections->route->toArray() : [];
354            $collectionRoutes
355                = array_merge(['record' => 'collection'], $routeConfig);
356            $routeName = $this->event->getRouteMatch()->getMatchedRouteName() ?? '';
357            if ($collectionRoute = ($collectionRoutes[$routeName] ?? null)) {
358                $driver = $this->loadRecord();
359                if (true === $driver->tryMethod('isCollection')) {
360                    $params = $this->params()->fromQuery()
361                        + $this->params()->fromRoute();
362                    // Disable path normalization since it can unencode e.g. encoded
363                    // slashes in record id's
364                    $options = [
365                        'normalize_path' => false,
366                    ];
367                    if ($sid = $this->getSearchMemory()->getCurrentSearchId()) {
368                        $options['query'] = compact('sid');
369                    }
370                    $collectionUrl = $this->url()
371                        ->fromRoute($collectionRoute, $params, $options);
372                    return $this->redirect()->toUrl($collectionUrl);
373                }
374            }
375        }
376
377        return $this->showTab(
378            $this->params()->fromRoute('tab', $this->getDefaultTab())
379        );
380    }
381
382    /**
383     * AJAX tab action -- render a tab without surrounding context.
384     *
385     * @return mixed
386     */
387    public function ajaxtabAction()
388    {
389        $this->disableSessionWrites();
390        $this->loadRecord();
391        // Set layout to render content only:
392        $this->layout()->setTemplate('layout/lightbox');
393        $this->layout()->setVariable('layoutContext', 'tabs');
394        return $this->showTab(
395            $this->params()->fromPost('tab', $this->getDefaultTab()),
396            true
397        );
398    }
399
400    /**
401     * ProcessSave -- store the results of the Save action.
402     *
403     * @return mixed
404     */
405    protected function processSave()
406    {
407        // Retrieve user object and force login if necessary:
408        if (!($user = $this->getUser())) {
409            return $this->forceLogin();
410        }
411
412        // Perform the save operation:
413        $driver = $this->loadRecord();
414        $post = $this->getRequest()->getPost()->toArray();
415        $tagsService = $this->serviceLocator->get(TagsService::class);
416        $post['mytags'] = $tagsService->parse($post['mytags'] ?? '');
417        $favorites = $this->serviceLocator->get(\VuFind\Favorites\FavoritesService::class);
418        $results = $favorites->saveRecordToFavorites($post, $user, $driver);
419
420        // Display a success status message:
421        $listUrl = $this->url()->fromRoute('userList', ['id' => $results['listId']]);
422        $message = [
423            'html' => true,
424            'msg' => $this->translate('bulk_save_success') . '. '
425                . '<a href="' . $listUrl . '" class="gotolist">'
426                . $this->translate('go_to_list') . '</a>.',
427        ];
428        $this->flashMessenger()->addMessage($message, 'success');
429
430        // redirect to followup url saved in saveAction
431        if ($url = $this->getAndClearFollowupUrl()) {
432            return $this->redirect()->toUrl($url);
433        }
434
435        // No followup info found?  Send back to record view:
436        return $this->redirectToRecord();
437    }
438
439    /**
440     * Save action - Allows the save template to appear,
441     *   passes containingLists & nonContainingLists
442     *
443     * @return mixed
444     */
445    public function saveAction()
446    {
447        // Fail if lists are disabled:
448        if (!$this->listsEnabled()) {
449            throw new ForbiddenException('Lists disabled');
450        }
451
452        // Check permission:
453        $response = $this->permission()->check('feature.Favorites', false);
454        if (is_object($response)) {
455            return $response;
456        }
457
458        // Process form submission:
459        if ($this->formWasSubmitted()) {
460            return $this->processSave();
461        }
462
463        // Retrieve user object and force login if necessary:
464        if (!($user = $this->getUser())) {
465            return $this->forceLogin();
466        }
467
468        // If we got this far, we should save the referer for later use by the
469        // ProcessSave action (to get back to where we came from after saving).
470        // We shouldn't save follow-up information if it points to the Save
471        // screen or the "create list" screen, as this causes confusing workflows;
472        // in these cases, we will simply push the user to record view
473        // by unsetting the followup and relying on default behavior in processSave.
474        $referer = $this->getRequest()->getServer()->get('HTTP_REFERER');
475        if (
476            !str_ends_with($referer, '/Save')
477            && stripos($referer, 'MyResearch/EditList/NEW') === false
478            && $this->isLocalUrl($referer)
479        ) {
480            $this->setFollowupUrlToReferer();
481        } else {
482            $this->clearFollowupUrl();
483        }
484
485        // Retrieve the record driver:
486        $driver = $this->loadRecord();
487
488        // Find out if the item is already part of any lists; save list info/IDs
489        $listIds = [];
490        $resources = $this->getDbService(UserResourceServiceInterface::class)->getFavoritesForRecord(
491            $driver->getUniqueId(),
492            $driver->getSourceIdentifier(),
493            null,
494            $user
495        );
496        foreach ($resources as $userResource) {
497            if ($currentList = $userResource->getUserList()) {
498                $listIds[] = $currentList->getId();
499            }
500        }
501
502        // Loop through all user lists and sort out containing/non-containing lists
503        $containingLists = $nonContainingLists = [];
504        foreach ($this->getDbService(UserListServiceInterface::class)->getUserListsByUser($user) as $list) {
505            // Assign list to appropriate array based on whether or not we found
506            // it earlier in the list of lists containing the selected record.
507            if (in_array($list->getId(), $listIds)) {
508                $containingLists[] = $list;
509            } else {
510                $nonContainingLists[] = $list;
511            }
512        }
513
514        $view = $this->createViewModel(
515            [
516                'containingLists' => $containingLists,
517                'nonContainingLists' => $nonContainingLists,
518            ]
519        );
520        $view->setTemplate('record/save');
521        return $view;
522    }
523
524    /**
525     * Email action - Allows the email form to appear.
526     *
527     * @return \Laminas\View\Model\ViewModel
528     */
529    public function emailAction()
530    {
531        // Force login if necessary:
532        $config = $this->getConfig();
533        if (
534            (!isset($config->Mail->require_login) || $config->Mail->require_login)
535            && !$this->getUser()
536        ) {
537            return $this->forceLogin();
538        }
539
540        // Retrieve the record driver:
541        $driver = $this->loadRecord();
542
543        // Create view
544        $mailer = $this->serviceLocator->get(\VuFind\Mailer\Mailer::class);
545        $view = $this->createEmailViewModel(
546            null,
547            $mailer->getDefaultRecordSubject($driver)
548        );
549        $mailer->setMaxRecipients($view->maxRecipients);
550
551        // Set up Captcha
552        $view->useCaptcha = $this->captcha()->active('email');
553        // Process form submission:
554        if ($this->formWasSubmitted(useCaptcha: $view->useCaptcha)) {
555            // Attempt to send the email and show an appropriate flash message:
556            try {
557                $cc = $this->params()->fromPost('ccself') && $view->from != $view->to
558                    ? $view->from : null;
559                $mailer->sendRecord(
560                    $view->to,
561                    $view->from,
562                    $view->message,
563                    $driver,
564                    $this->getViewRenderer(),
565                    $view->subject,
566                    $cc
567                );
568                $this->flashMessenger()->addMessage('email_success', 'success');
569                return $this->redirectToRecord();
570            } catch (MailException $e) {
571                $this->flashMessenger()->addMessage($e->getDisplayMessage(), 'error');
572            }
573        }
574
575        // Display the template:
576        $view->setTemplate('record/email');
577        return $view;
578    }
579
580    /**
581     * Is SMS enabled?
582     *
583     * @return bool
584     */
585    protected function smsEnabled()
586    {
587        $check = $this->serviceLocator
588            ->get(\VuFind\Config\AccountCapabilities::class);
589        return $check->getSmsSetting() !== 'disabled';
590    }
591
592    /**
593     * SMS action - Allows the SMS form to appear.
594     *
595     * @return \Laminas\View\Model\ViewModel
596     */
597    public function smsAction()
598    {
599        // Make sure comments are enabled:
600        if (!$this->smsEnabled()) {
601            throw new ForbiddenException('SMS disabled');
602        }
603
604        // Retrieve the record driver:
605        $driver = $this->loadRecord();
606
607        // Load the SMS carrier list:
608        $sms = $this->serviceLocator->get(\VuFind\SMS\SMSInterface::class);
609        $view = $this->createViewModel();
610        $view->carriers = $sms->getCarriers();
611        $view->validation = $sms->getValidationType();
612        // Set up Captcha
613        $view->useCaptcha = $this->captcha()->active('sms');
614        // Send parameters back to view so form can be re-populated:
615        $view->to = $this->params()->fromPost('to');
616        $view->provider = $this->params()->fromPost('provider');
617        // Process form submission:
618        if ($this->formWasSubmitted(useCaptcha: $view->useCaptcha)) {
619            // Do CSRF check
620            $csrf = $this->serviceLocator->get(\VuFind\Validator\SessionCsrf::class);
621            if (!$csrf->isValid($this->getRequest()->getPost()->get('csrf'))) {
622                throw new \VuFind\Exception\BadRequest(
623                    'error_inconsistent_parameters'
624                );
625            }
626
627            // Attempt to send the email and show an appropriate flash message:
628            try {
629                $body = $this->getViewRenderer()->partial(
630                    'Email/record-sms.phtml',
631                    ['driver' => $driver, 'to' => $view->to]
632                );
633                $sms->text($view->provider, $view->to, null, $body);
634                $this->flashMessenger()->addMessage('sms_success', 'success');
635                return $this->redirectToRecord();
636            } catch (MailException $e) {
637                $this->flashMessenger()->addMessage($e->getDisplayMessage(), 'error');
638            }
639        }
640
641        // Display the template:
642        $view->setTemplate('record/sms');
643        return $view;
644    }
645
646    /**
647     * Show citations for the current record.
648     *
649     * @return \Laminas\View\Model\ViewModel
650     */
651    public function citeAction()
652    {
653        $view = $this->createViewModel();
654        $view->setTemplate('record/cite');
655        return $view;
656    }
657
658    /**
659     * Show permanent link for the current record.
660     *
661     * @return \Laminas\View\Model\ViewModel
662     */
663    public function permalinkAction()
664    {
665        $view = $this->createViewModel();
666        $view->setTemplate('record/permalink');
667        return $view;
668    }
669
670    /**
671     * Export the record
672     *
673     * @return mixed
674     */
675    public function exportAction()
676    {
677        $driver = $this->loadRecord();
678        $view = $this->createViewModel();
679        $format = $this->params()->fromQuery('style');
680
681        // Display export menu if missing/invalid option
682        $export = $this->serviceLocator->get(\VuFind\Export::class);
683        if (empty($format) || !$export->recordSupportsFormat($driver, $format)) {
684            if (!empty($format)) {
685                $this->flashMessenger()
686                    ->addMessage('export_invalid_format', 'error');
687            }
688            $view->setTemplate('record/export-menu');
689            return $view;
690        }
691
692        // If this is an export format that redirects to an external site, perform
693        // the redirect now (unless we're being called back from that service!):
694        if (
695            $export->needsRedirect($format)
696            && !$this->params()->fromQuery('callback')
697        ) {
698            // Build callback URL:
699            $parts = explode('?', $this->getServerUrl(true));
700            $callback = $parts[0] . '?callback=1&style=' . urlencode($format);
701
702            return $this->redirect()
703                ->toUrl($export->getRedirectUrl($format, $callback));
704        }
705
706        $recordHelper = $this->getViewRenderer()->plugin('record');
707        try {
708            $exportedRecord = $recordHelper($driver)->getExport($format);
709        } catch (\VuFind\Exception\FormatUnavailable $e) {
710            $this->flashMessenger()->addErrorMessage('export_unsupported_format');
711            return $this->redirectToRecord();
712        }
713
714        $exportType = $export->getBulkExportType($format);
715        if ('post' === $exportType) {
716            $params = [
717                'exportType' => 'post',
718                'postField' => $export->getPostField($format),
719                'postData' => $exportedRecord,
720                'targetWindow' => $export->getTargetWindow($format),
721                'url' => $export->getRedirectUrl($format, ''),
722                'format' => $format,
723            ];
724            $msg = [
725                'translate' => false, 'html' => true,
726                'msg' => $this->getViewRenderer()->render(
727                    'cart/export-success.phtml',
728                    $params
729                ),
730            ];
731            $this->flashMessenger()->addSuccessMessage($msg);
732            return $this->redirectToRecord();
733        }
734
735        // Send appropriate HTTP headers for requested format:
736        $response = $this->getResponse();
737        $response->getHeaders()->addHeaders($export->getHeaders($format));
738
739        // Actually export the record
740        $response->setContent($exportedRecord);
741        return $response;
742    }
743
744    /**
745     * Special action for RDF export
746     *
747     * @return mixed
748     */
749    public function rdfAction()
750    {
751        $this->getRequest()->getQuery()->set('style', 'RDF');
752        return $this->exportAction();
753    }
754
755    /**
756     * Show explanation for why a record was found and how its relevancy is computed
757     *
758     * @return mixed
759     */
760    public function explainAction()
761    {
762        $record = $this->loadRecord();
763
764        $view = $this->createViewModel();
765        $view->setTemplate('record/explain');
766        if (!$record->tryMethod('explainEnabled')) {
767            $view->disabled = true;
768            return $view;
769        }
770
771        $explanation = $this->serviceLocator
772            ->get(\VuFind\Search\Explanation\PluginManager::class)
773            ->get($record->getSourceIdentifier());
774
775        $params = $explanation->getParams();
776        $params->initFromRequest($this->getRequest()->getQuery());
777        $explanation->performRequest($record->getUniqueID());
778
779        $view->explanation = $explanation;
780        return $view;
781    }
782
783    /**
784     * Load the record requested by the user; note that this is not done in the
785     * init() method since we don't want to perform an expensive search twice
786     * when homeAction() forwards to another method.
787     *
788     * @param ParamBag $params Search backend parameters
789     * @param bool     $force  Set to true to force a reload of the record, even if
790     * already loaded (useful if loading a record using different parameters)
791     *
792     * @return AbstractRecordDriver
793     */
794    protected function loadRecord(ParamBag $params = null, bool $force = false)
795    {
796        // Only load the record if it has not already been loaded. Note that
797        // when determining record ID, we check both the route match (the most
798        // common scenario) and the GET parameters (a fallback used by some
799        // legacy routes).
800        if ($force || !is_object($this->driver)) {
801            $recordLoader = $this->getRecordLoader();
802            $cacheContext = $this->getRequest()->getQuery()->get('cacheContext');
803            if (isset($cacheContext)) {
804                $recordLoader->setCacheContext($cacheContext);
805            }
806            $this->driver = $recordLoader->load(
807                $this->params()->fromRoute('id', $this->params()->fromQuery('id')),
808                $this->sourceId,
809                false,
810                $params
811            );
812        }
813        return $this->driver;
814    }
815
816    /**
817     * Redirect the user to the main record view.
818     *
819     * @param string $params Parameters to append to record URL.
820     * @param string $tab    Record tab to display (null for default).
821     *
822     * @return mixed
823     */
824    protected function redirectToRecord($params = '', $tab = null)
825    {
826        $details = $this->getRecordRouter()
827            ->getTabRouteDetails($this->loadRecord(), $tab);
828        $target = $this->url()->fromRoute($details['route'], $details['params']);
829
830        return $this->redirect()->toUrl($target . $params);
831    }
832
833    /**
834     * Support method to load tab information from the RecordTab PluginManager.
835     *
836     * @return void
837     */
838    protected function loadTabDetails()
839    {
840        $driver = $this->loadRecord();
841        $request = $this->getRequest();
842        $manager = $this->getRecordTabManager();
843        $details = $manager
844            ->getTabDetailsForRecord($driver, $request, $this->fallbackDefaultTab);
845        $this->allTabs = $details['tabs'];
846        $this->defaultTab = $details['default'] ? $details['default'] : false;
847        $this->backgroundTabs = $manager->getBackgroundTabNames($driver);
848        $this->tabsExtraScripts = $manager->getExtraScripts();
849    }
850
851    /**
852     * Get default tab for a given driver
853     *
854     * @return string
855     */
856    protected function getDefaultTab()
857    {
858        // Load default tab if not already retrieved:
859        if (null === $this->defaultTab) {
860            $this->loadTabDetails();
861        }
862        return $this->defaultTab;
863    }
864
865    /**
866     * Get all tab information for a given driver.
867     *
868     * @return array
869     */
870    protected function getAllTabs()
871    {
872        if (null === $this->allTabs) {
873            $this->loadTabDetails();
874        }
875        return $this->allTabs;
876    }
877
878    /**
879     * Get names of tabs to be loaded in the background.
880     *
881     * @return array
882     */
883    protected function getBackgroundTabs()
884    {
885        if (null === $this->backgroundTabs) {
886            $this->loadTabDetails();
887        }
888        return $this->backgroundTabs;
889    }
890
891    /**
892     * Get extra scripts required by tabs.
893     *
894     * @param array $tabs Tab names to consider
895     *
896     * @return array
897     */
898    protected function getTabsExtraScripts($tabs)
899    {
900        if (null === $this->tabsExtraScripts) {
901            $this->loadTabDetails();
902        }
903        $allScripts = [];
904        foreach (array_keys($tabs) as $tab) {
905            if (!empty($this->tabsExtraScripts[$tab])) {
906                $allScripts
907                    = array_merge($allScripts, $this->tabsExtraScripts[$tab]);
908            }
909        }
910        return array_unique($allScripts);
911    }
912
913    /**
914     * Is the result scroller active?
915     *
916     * @return bool
917     */
918    protected function resultScrollerActive()
919    {
920        // Disabled by default:
921        return false;
922    }
923
924    /**
925     * Display a particular tab.
926     *
927     * @param string $tab  Name of tab to display
928     * @param bool   $ajax Are we in AJAX mode?
929     *
930     * @return mixed
931     */
932    protected function showTab($tab, $ajax = false)
933    {
934        // Special case -- handle login request (currently needed for holdings
935        // tab when driver-based holds mode is enabled, but may also be useful
936        // in other circumstances):
937        if (
938            $this->params()->fromQuery('login', 'false') == 'true'
939            && !$this->getUser()
940        ) {
941            return $this->forceLogin(null);
942        } elseif (
943            $this->params()->fromQuery('catalogLogin', 'false') == 'true'
944            && !is_array($patron = $this->catalogLogin())
945        ) {
946            return $patron;
947        }
948
949        $config = $this->getConfig();
950
951        $view = $this->createViewModel();
952        $view->tabs = $this->getAllTabs();
953        $view->activeTab = strtolower($tab);
954        $view->defaultTab = strtolower($this->getDefaultTab());
955        $view->backgroundTabs = $this->getBackgroundTabs();
956        $view->tabsExtraScripts = $this->getTabsExtraScripts($view->tabs);
957        $view->loadInitialTabWithAjax
958            = isset($config->Site->loadInitialTabWithAjax)
959            ? (bool)$config->Site->loadInitialTabWithAjax : false;
960
961        // Set up next/previous record links (if appropriate)
962        if ($this->resultScrollerActive()) {
963            $driver = $this->loadRecord();
964            $view->scrollData = $this->resultScroller()->getScrollData($driver);
965        }
966
967        $view->callnumberHandler = $config->Item_Status->callnumber_handler ?? false;
968
969        $view->setTemplate($ajax ? 'record/ajaxtab' : 'record/view');
970        return $view;
971    }
972}