Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 228
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
GetItemStatuses
0.00% covered (danger)
0.00%
0 / 228
0.00% covered (danger)
0.00%
0 / 13
2652
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 filterSuppressedLocations
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 translateList
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 pickValue
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
56
 getCallnumberHandler
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 reduceServices
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 formatCallNo
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getItemStatus
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
42
 getItemStatusGroup
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
72
 getItemStatusError
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 getAvailabilityMessage
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 renderFullStatus
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 handleRequest
0.00% covered (danger)
0.00%
0 / 67
0.00% covered (danger)
0.00%
0 / 1
132
1<?php
2
3/**
4 * "Get Item Status" AJAX handler
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2018.
9 * Copyright (C) The National Library of Finland 2023.
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  AJAX
26 * @author   Demian Katz <demian.katz@villanova.edu>
27 * @author   Chris Delis <cedelis@uillinois.edu>
28 * @author   Tuan Nguyen <tuan@yorku.ca>
29 * @author   Ere Maijala <ere.maijala@helsinki.fi>
30 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
31 * @link     https://vufind.org/wiki/development Wiki
32 */
33
34namespace VuFind\AjaxHandler;
35
36use Laminas\Config\Config;
37use Laminas\Mvc\Controller\Plugin\Params;
38use Laminas\View\Renderer\RendererInterface;
39use VuFind\Exception\ILS as ILSException;
40use VuFind\I18n\Translator\TranslatorAwareInterface;
41use VuFind\ILS\Connection;
42use VuFind\ILS\Logic\AvailabilityStatusInterface;
43use VuFind\ILS\Logic\AvailabilityStatusManager;
44use VuFind\ILS\Logic\Holds;
45use VuFind\Session\Settings as SessionSettings;
46
47use function count;
48use function in_array;
49use function is_array;
50
51/**
52 * "Get Item Status" AJAX handler
53 *
54 * This is responsible for printing the holdings information for a
55 * collection of records in JSON format.
56 *
57 * @category VuFind
58 * @package  AJAX
59 * @author   Demian Katz <demian.katz@villanova.edu>
60 * @author   Chris Delis <cedelis@uillinois.edu>
61 * @author   Tuan Nguyen <tuan@yorku.ca>
62 * @author   Ere Maijala <ere.maijala@helsinki.fi>
63 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
64 * @link     https://vufind.org/wiki/development Wiki
65 */
66class GetItemStatuses extends AbstractBase implements
67    TranslatorAwareInterface,
68    \VuFind\I18n\HasSorterInterface
69{
70    use \VuFind\I18n\Translator\TranslatorAwareTrait;
71    use \VuFind\I18n\HasSorterTrait;
72
73    /**
74     * Constructor
75     *
76     * @param SessionSettings           $ss                        Session settings
77     * @param Config                    $config                    Top-level configuration
78     * @param Connection                $ils                       ILS connection
79     * @param RendererInterface         $renderer                  View renderer
80     * @param Holds                     $holdLogic                 Holds logic
81     * @param AvailabilityStatusManager $availabilityStatusManager Availability status manager
82     */
83    public function __construct(
84        SessionSettings $ss,
85        protected Config $config,
86        protected Connection $ils,
87        protected RendererInterface $renderer,
88        protected Holds $holdLogic,
89        protected AvailabilityStatusManager $availabilityStatusManager
90    ) {
91        $this->sessionSettings = $ss;
92    }
93
94    /**
95     * Support method for getItemStatuses() -- filter suppressed locations from the
96     * array of item information for a particular bib record.
97     *
98     * @param array $record Information on items linked to a single bib record
99     *
100     * @return array        Filtered version of $record
101     */
102    protected function filterSuppressedLocations($record)
103    {
104        static $hideHoldings = false;
105        if ($hideHoldings === false) {
106            $hideHoldings = $this->holdLogic->getSuppressedLocations();
107        }
108
109        $filtered = [];
110        foreach ($record as $current) {
111            if (!in_array($current['location'] ?? null, $hideHoldings)) {
112                $filtered[] = $current;
113            }
114        }
115        return $filtered;
116    }
117
118    /**
119     * Translate an array of strings using a prefix.
120     *
121     * @param string $transPrefix Translation prefix
122     * @param array  $list        List of values to translate
123     *
124     * @return array
125     */
126    protected function translateList($transPrefix, $list)
127    {
128        $transList = [];
129        foreach ($list as $current) {
130            $transList[] = $this->translateWithPrefix($transPrefix, $current);
131        }
132        return $transList;
133    }
134
135    /**
136     * Support method for getItemStatuses() -- when presented with multiple values,
137     * pick which one(s) to send back via AJAX.
138     *
139     * @param array  $rawList     Array of values to choose from.
140     * @param string $mode        config.ini setting -- first, all or msg
141     * @param string $msg         Message to display if $mode == "msg"
142     * @param string $transPrefix Translator prefix to apply to values (false to
143     * omit translation of values)
144     *
145     * @return string
146     */
147    protected function pickValue($rawList, $mode, $msg, $transPrefix = false)
148    {
149        // Make sure array contains only unique values:
150        $list = array_unique($rawList);
151
152        // If there is only one value in the list, or if we're in "first" mode,
153        // send back the first list value:
154        if ($mode == 'first' || count($list) == 1) {
155            if ($transPrefix) {
156                return $this->translateWithPrefix($transPrefix, $list[0]);
157            }
158            return $list[0];
159        } elseif (count($list) == 0) {
160            // Empty list?  Return a blank string:
161            return '';
162        } elseif ($mode == 'all') {
163            // All values mode?  Return comma-separated values:
164            return implode(
165                ",\t",
166                $transPrefix ? $this->translateList($transPrefix, $list) : $list
167            );
168        } else {
169            // Message mode?  Return the specified message, translated to the
170            // appropriate language.
171            return $this->translate($msg);
172        }
173    }
174
175    /**
176     * Based on settings and the number of callnumbers, return callnumber handler
177     * Use callnumbers before pickValue is run.
178     *
179     * @param array  $list           Array of callnumbers.
180     * @param string $displaySetting config.ini setting -- first, all or msg
181     *
182     * @return string
183     */
184    protected function getCallnumberHandler($list = null, $displaySetting = null)
185    {
186        if ($displaySetting == 'msg' && count($list) > 1) {
187            return false;
188        }
189        return $this->config->Item_Status->callnumber_handler ?? false;
190    }
191
192    /**
193     * Reduce an array of service names to a human-readable string.
194     *
195     * @param array $rawServices Names of available services.
196     *
197     * @return string
198     */
199    protected function reduceServices(array $rawServices)
200    {
201        // Normalize, dedup and sort available services
202        $normalize = function ($in) {
203            return strtolower(preg_replace('/[^A-Za-z]/', '', $in));
204        };
205        $services = array_map($normalize, array_unique($rawServices));
206        $this->getSorter()->sort($services);
207
208        // Do we need to deal with a preferred service?
209        $preferred = isset($this->config->Item_Status->preferred_service)
210            ? $normalize($this->config->Item_Status->preferred_service) : false;
211        if (false !== $preferred && in_array($preferred, $services)) {
212            $services = [$preferred];
213        }
214
215        return $this->renderer->render(
216            'ajax/status-available-services.phtml',
217            ['services' => $services]
218        );
219    }
220
221    /**
222     * Create a delimited version of the call number to allow the Javascript code
223     * to handle the prefix appropriately.
224     *
225     * @param string $prefix     Callnumber prefix or empty string.
226     * @param string $callnumber Main call number.
227     *
228     * @return string
229     */
230    protected function formatCallNo($prefix, $callnumber)
231    {
232        return !empty($prefix) ? $prefix . '::::' . $callnumber : $callnumber;
233    }
234
235    /**
236     * Support method for getItemStatuses() -- process a single bibliographic record
237     * for location settings other than "group".
238     *
239     * @param array  $record            Information on items linked to a single bib
240     *                                  record
241     * @param string $locationSetting   The location mode setting used for
242     *                                  pickValue()
243     * @param string $callnumberSetting The callnumber mode setting used for
244     *                                  pickValue()
245     *
246     * @return array                    Summarized availability information
247     */
248    protected function getItemStatus(
249        $record,
250        $locationSetting,
251        $callnumberSetting
252    ) {
253        // Summarize call number, location and availability info across all items:
254        $callNumbers = $locations = [];
255        $services = [];
256        foreach ($record as $info) {
257            // Store call number/location info:
258            $callNumbers[] = $this->formatCallNo(
259                $info['callnumber_prefix'] ?? '',
260                $info['callnumber']
261            );
262
263            $locations[] = $info['location'];
264            // Store all available services
265            if (isset($info['services'])) {
266                $services = array_merge($services, $info['services']);
267            }
268        }
269
270        $callnumberHandler = $this->getCallnumberHandler(
271            $callNumbers,
272            $callnumberSetting
273        );
274
275        // Determine call number string based on findings:
276        $callNumber = $this->pickValue(
277            $callNumbers,
278            $callnumberSetting,
279            'Multiple Call Numbers'
280        );
281
282        // Determine location string based on findings:
283        $location = $this->pickValue(
284            $locations,
285            $locationSetting,
286            'Multiple Locations',
287            'location_'
288        );
289
290        // Get combined availability
291        $combinedInfo = $this->availabilityStatusManager->combine($record);
292        $combinedAvailability = $combinedInfo['availability'];
293
294        if (!empty($services)) {
295            $availabilityMessage = $this->reduceServices($services);
296        } else {
297            $availabilityMessage = $this->getAvailabilityMessage($combinedAvailability);
298        }
299
300        $reserve = ($record[0]['reserve'] ?? 'N') === 'Y';
301
302        // Send back the collected details:
303        return [
304            'id' => $record[0]['id'],
305            'availability' => $combinedAvailability->availabilityAsString(),
306            'availability_message' => $availabilityMessage,
307            'location' => htmlentities($location, ENT_COMPAT, 'UTF-8'),
308            'locationList' => false,
309            'reserve' => $reserve ? 'true' : 'false',
310            'reserve_message'
311                => $this->translate($reserve ? 'on_reserve' : 'Not On Reserve'),
312            'callnumber' => htmlentities($callNumber, ENT_COMPAT, 'UTF-8'),
313            'callnumber_handler' => $callnumberHandler,
314        ];
315    }
316
317    /**
318     * Support method for getItemStatuses() -- process a single bibliographic record
319     * for "group" location setting.
320     *
321     * @param array  $record            Information on items linked to a single
322     *                                  bib record
323     * @param string $callnumberSetting The callnumber mode setting used for
324     *                                  pickValue()
325     *
326     * @return array                    Summarized availability information
327     */
328    protected function getItemStatusGroup($record, $callnumberSetting)
329    {
330        // Summarize call number, location and availability info across all items:
331        $locations = [];
332        foreach ($record as $info) {
333            $availabilityStatus = $info['availability'];
334            // Find an available copy
335            if ($availabilityStatus->isAvailable()) {
336                if ('true' !== ($locations[$info['location']]['available'] ?? null)) {
337                    $locations[$info['location']]['available'] = $availabilityStatus->getStatusDescription();
338                }
339            }
340            // Check for a use_unknown_message flag
341            if ($availabilityStatus->is(AvailabilityStatusInterface::STATUS_UNKNOWN)) {
342                $locations[$info['location']]['status_unknown'] = true;
343            }
344            // Store call number/location info:
345            $locations[$info['location']]['callnumbers'][] = $this->formatCallNo(
346                $info['callnumber_prefix'] ?? '',
347                $info['callnumber']
348            );
349        }
350
351        // Build list split out by location:
352        $locationList = [];
353        foreach ($locations as $location => $details) {
354            $locationCallnumbers = array_unique($details['callnumbers']);
355            // Determine call number string based on findings:
356            $callnumberHandler = $this->getCallnumberHandler(
357                $locationCallnumbers,
358                $callnumberSetting
359            );
360            $locationCallnumbers = $this->pickValue(
361                $locationCallnumbers,
362                $callnumberSetting,
363                'Multiple Call Numbers'
364            );
365            $locationInfo = [
366                'availability' => $details['available'] ?? false,
367                'location' => htmlentities(
368                    $this->translateWithPrefix('location_', $location),
369                    ENT_COMPAT,
370                    'UTF-8'
371                ),
372                'callnumbers' =>
373                    htmlentities($locationCallnumbers, ENT_COMPAT, 'UTF-8'),
374                'status_unknown' => $details['status_unknown'] ?? false,
375                'callnumber_handler' => $callnumberHandler,
376            ];
377            $locationList[] = $locationInfo;
378        }
379
380        // Get combined availability
381        $combinedInfo = $this->availabilityStatusManager->combine($record);
382        $combinedAvailability = $combinedInfo['availability'];
383
384        $reserve = ($record[0]['reserve'] ?? 'N') === 'Y';
385
386        // Send back the collected details:
387        return [
388            'id' => $record[0]['id'],
389            'availability' => $combinedAvailability->availabilityAsString(),
390            'availability_message' => $this->getAvailabilityMessage($combinedAvailability),
391            'location' => false,
392            'locationList' => $locationList,
393            'reserve' => $reserve ? 'true' : 'false',
394            'reserve_message'
395                => $this->translate($reserve ? 'on_reserve' : 'Not On Reserve'),
396            'callnumber' => false,
397        ];
398    }
399
400    /**
401     * Support method for getItemStatuses() -- process a failed record.
402     *
403     * @param array  $record Information on items linked to a single bib record
404     * @param string $msg    Availability message
405     *
406     * @return array Summarized availability information
407     */
408    protected function getItemStatusError($record, $msg = '')
409    {
410        return [
411            'id' => $record[0]['id'],
412            'error' => $this->translate($record[0]['error']),
413            'availability' => false,
414            'availability_message' => $msg,
415            'location' => false,
416            'locationList' => [],
417            'reserve' => false,
418            'reserve_message' => '',
419            'callnumber' => false,
420        ];
421    }
422
423    /**
424     * Get a message for availability status
425     *
426     * @param AvailabilityStatusInterface $availability Availability Status
427     *
428     * @return string
429     */
430    protected function getAvailabilityMessage(AvailabilityStatusInterface $availability): string
431    {
432        return $this->renderer->render(
433            'ajax/status.phtml',
434            ['availabilityStatus' => $availability]
435        );
436    }
437
438    /**
439     * Render full item status.
440     *
441     * @param array $record       Record
442     * @param array $simpleStatus Simple status result
443     * @param array $values       Additional values for the template
444     *
445     * @return string
446     */
447    protected function renderFullStatus($record, $simpleStatus, array $values = [])
448    {
449        $values = array_merge(
450            [
451                'statusItems' => $record,
452                'simpleStatus' => $simpleStatus,
453                'callnumberHandler' => $this->getCallnumberHandler(),
454            ],
455            $values
456        );
457        return $this->renderer->render('ajax/status-full.phtml', $values);
458    }
459
460    /**
461     * Handle a request.
462     *
463     * @param Params $params Parameter helper from controller
464     *
465     * @return array [response data, HTTP status code]
466     */
467    public function handleRequest(Params $params)
468    {
469        $results = [];
470        $this->disableSessionWrites();  // avoid session write timing bug
471        $ids = $params->fromPost('id') ?? $params->fromQuery('id', []);
472        $searchId = $params->fromPost('sid') ?? $params->fromQuery('sid');
473        try {
474            $results = $this->ils->getStatuses($ids);
475        } catch (ILSException $e) {
476            // If the ILS fails, send an error response instead of a fatal
477            // error; we don't want to confuse the end user unnecessarily.
478            error_log($e->getMessage());
479            foreach ($ids as $id) {
480                $results[] = [
481                    [
482                        'id' => $id,
483                        'error' => 'An error has occurred',
484                    ],
485                ];
486            }
487        }
488
489        if (!is_array($results)) {
490            // If getStatuses returned garbage, let's turn it into an empty array
491            // to avoid triggering a notice in the foreach loop below.
492            $results = [];
493        }
494
495        // In order to detect IDs missing from the status response, create an
496        // array with a key for every requested ID. We will clear keys as we
497        // encounter IDs in the response -- anything left will be problems that
498        // need special handling.
499        $missingIds = array_flip($ids);
500
501        // Load callnumber and location settings:
502        $callnumberSetting = $this->config->Item_Status->multiple_call_nos ?? 'msg';
503        $locationSetting = $this->config->Item_Status->multiple_locations ?? 'msg';
504        $showFullStatus = $this->config->Item_Status->show_full_status ?? false;
505
506        // Loop through all the status information that came back
507        $statuses = [];
508        foreach ($results as $recordNumber => $record) {
509            // Filter out suppressed locations:
510            $record = $this->filterSuppressedLocations($record);
511
512            // Skip empty records:
513            if (count($record)) {
514                // Check for errors
515                if (!empty($record[0]['error'])) {
516                    $unknownStatus = $this->availabilityStatusManager->createAvailabilityStatus(
517                        AvailabilityStatusInterface::STATUS_UNKNOWN
518                    );
519                    $current = $this
520                        ->getItemStatusError(
521                            $record,
522                            $this->getAvailabilityMessage($unknownStatus)
523                        );
524                } elseif ($locationSetting === 'group') {
525                    $current = $this->getItemStatusGroup(
526                        $record,
527                        $callnumberSetting
528                    );
529                } else {
530                    $current = $this->getItemStatus(
531                        $record,
532                        $locationSetting,
533                        $callnumberSetting
534                    );
535                }
536                // If a full status display has been requested and no errors were
537                // encountered, append the HTML:
538                if ($showFullStatus && empty($record[0]['error'])) {
539                    $current['full_status'] = $this->renderFullStatus(
540                        $record,
541                        $current,
542                        compact('searchId', 'current'),
543                    );
544                }
545                $current['record_number'] = array_search($current['id'], $ids);
546                $statuses[] = $current;
547
548                // The current ID is not missing -- remove it from the missing list.
549                unset($missingIds[$current['id']]);
550            }
551        }
552
553        // If any IDs were missing, send back appropriate dummy data
554        foreach ($missingIds as $missingId => $recordNumber) {
555            $availabilityStatus = $this->availabilityStatusManager->createAvailabilityStatus(false);
556            $statuses[] = [
557                'id'                   => $missingId,
558                'availability'         => 'false',
559                'availability_message' => $this->getAvailabilityMessage($availabilityStatus),
560                'location'             => $this->translate('Unknown'),
561                'locationList'         => false,
562                'reserve'              => 'false',
563                'reserve_message'      => $this->translate('Not On Reserve'),
564                'callnumber'           => '',
565                'missing_data'         => true,
566                'record_number'        => $recordNumber,
567            ];
568        }
569
570        // Done
571        return $this->formatResponse(compact('statuses'));
572    }
573}