Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 228 |
|
0.00% |
0 / 13 |
CRAP | |
0.00% |
0 / 1 |
GetItemStatuses | |
0.00% |
0 / 228 |
|
0.00% |
0 / 13 |
2652 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
filterSuppressedLocations | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
translateList | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
pickValue | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
56 | |||
getCallnumberHandler | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
reduceServices | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
20 | |||
formatCallNo | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
getItemStatus | |
0.00% |
0 / 43 |
|
0.00% |
0 / 1 |
42 | |||
getItemStatusGroup | |
0.00% |
0 / 51 |
|
0.00% |
0 / 1 |
72 | |||
getItemStatusError | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
getAvailabilityMessage | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
renderFullStatus | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
handleRequest | |
0.00% |
0 / 67 |
|
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 | |
34 | namespace VuFind\AjaxHandler; |
35 | |
36 | use Laminas\Config\Config; |
37 | use Laminas\Mvc\Controller\Plugin\Params; |
38 | use Laminas\View\Renderer\RendererInterface; |
39 | use VuFind\Exception\ILS as ILSException; |
40 | use VuFind\I18n\Translator\TranslatorAwareInterface; |
41 | use VuFind\ILS\Connection; |
42 | use VuFind\ILS\Logic\AvailabilityStatusInterface; |
43 | use VuFind\ILS\Logic\AvailabilityStatusManager; |
44 | use VuFind\ILS\Logic\Holds; |
45 | use VuFind\Session\Settings as SessionSettings; |
46 | |
47 | use function count; |
48 | use function in_array; |
49 | use 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 | */ |
66 | class 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 | } |