Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
9.73% covered (danger)
9.73%
77 / 791
7.69% covered (danger)
7.69%
3 / 39
CRAP
0.00% covered (danger)
0.00%
0 / 1
Aleph
9.73% covered (danger)
9.73%
77 / 791
7.69% covered (danger)
7.69%
3 / 39
22957.93
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 init
5.45% covered (danger)
5.45%
3 / 55
0.00% covered (danger)
0.00%
0 / 1
393.70
 getDefaultAddressMappings
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 doXRequest
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
30
 doRestDLFRequest
38.46% covered (danger)
38.46%
10 / 26
0.00% covered (danger)
0.00%
0 / 1
18.42
 appendQueryString
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 doHTTPRequest
54.17% covered (warning)
54.17%
13 / 24
0.00% covered (danger)
0.00%
0 / 1
14.16
 parseId
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 getStatus
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getStatusesX
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
42
 getStatuses
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 getHolding
0.00% covered (danger)
0.00%
0 / 88
0.00% covered (danger)
0.00%
0 / 1
240
 getMyTransactionHistory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMyTransactions
0.00% covered (danger)
0.00%
0 / 65
0.00% covered (danger)
0.00%
0 / 1
90
 getRenewDetails
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 renewMyItems
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 getMyHolds
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
20
 getCancelHoldDetails
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 cancelHolds
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 getMyFines
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
3
 getMyProfile
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getMyProfileX
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
30
 getMyProfileDLF
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 patronLogin
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
72
 getHoldingInfoForItem
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
20
 getHoldDefaultRequiredDate
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 placeHold
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
42
 barcodeToID
8.70% covered (danger)
8.70%
2 / 23
0.00% covered (danger)
0.00%
0 / 1
33.40
 parseDate
27.27% covered (danger)
27.27%
3 / 11
0.00% covered (danger)
0.00%
0 / 1
25.85
 supportsMethod
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getConfig
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 getPickUpLocations
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 getDefaultPickUpLocation
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
42
 getPurchaseHistory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getNewItems
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDepartments
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getInstructors
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCourses
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 findReserves
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3/**
4 * Aleph ILS driver
5 *
6 * PHP version 8
7 *
8 * Copyright (C) UB/FU Berlin
9 *
10 * last update: 7.11.2007
11 * tested with X-Server Aleph 18.1.
12 *
13 * TODO: login, course information, getNewItems, duedate in holdings,
14 * https connection to x-server, ...
15 *
16 * This program is free software; you can redistribute it and/or modify
17 * it under the terms of the GNU General Public License version 2,
18 * as published by the Free Software Foundation.
19 *
20 * This program is distributed in the hope that it will be useful,
21 * but WITHOUT ANY WARRANTY; without even the implied warranty of
22 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
23 * GNU General Public License for more details.
24 *
25 * You should have received a copy of the GNU General Public License
26 * along with this program; if not, write to the Free Software
27 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
28 *
29 * @category VuFind
30 * @package  ILS_Drivers
31 * @author   Christoph Krempe <vufind-tech@lists.sourceforge.net>
32 * @author   Alan Rykhus <vufind-tech@lists.sourceforge.net>
33 * @author   Jason L. Cooper <vufind-tech@lists.sourceforge.net>
34 * @author   Kun Lin <vufind-tech@lists.sourceforge.net>
35 * @author   Vaclav Rosecky <vufind-tech@lists.sourceforge.net>
36 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
37 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
38 */
39
40namespace VuFind\ILS\Driver;
41
42use Laminas\I18n\Translator\TranslatorInterface;
43use VuFind\Date\DateException;
44use VuFind\Exception\ILS as ILSException;
45
46use function array_key_exists;
47use function count;
48use function in_array;
49use function is_callable;
50use function strlen;
51
52/**
53 * Aleph ILS driver
54 *
55 * @category VuFind
56 * @package  ILS_Drivers
57 * @author   Christoph Krempe <vufind-tech@lists.sourceforge.net>
58 * @author   Alan Rykhus <vufind-tech@lists.sourceforge.net>
59 * @author   Jason L. Cooper <vufind-tech@lists.sourceforge.net>
60 * @author   Kun Lin <vufind-tech@lists.sourceforge.net>
61 * @author   Vaclav Rosecky <vufind-tech@lists.sourceforge.net>
62 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
63 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
64 */
65class Aleph extends AbstractBase implements
66    \Laminas\Log\LoggerAwareInterface,
67    \VuFindHttp\HttpServiceAwareInterface
68{
69    use \VuFind\Log\LoggerAwareTrait;
70    use \VuFindHttp\HttpServiceAwareTrait;
71
72    public const RECORD_ID_BASE_SEPARATOR = '-';
73
74    /**
75     * Translator object
76     *
77     * @var Aleph\Translator
78     */
79    protected $alephTranslator = false;
80
81    /**
82     * Cache manager
83     *
84     * @var \VuFind\Cache\Manager
85     */
86    protected $cacheManager;
87
88    /**
89     * Translator
90     *
91     * @var TranslatorInterface
92     */
93    protected $translator;
94
95    /**
96     * Date converter object
97     *
98     * @var \VuFind\Date\Converter
99     */
100    protected $dateConverter = null;
101
102    /**
103     * The base URL, where the REST DLF API is running
104     *
105     * @var string
106     */
107    protected $dlfbaseurl = null;
108
109    /**
110     * Aleph server
111     *
112     * @var string
113     */
114    protected $host;
115
116    /**
117     * Bibliographic bases
118     *
119     * @var array
120     */
121    protected $bib;
122
123    /**
124     * User library
125     *
126     * @var string
127     */
128    protected $useradm;
129
130    /**
131     * Item library
132     *
133     * @var string
134     */
135    protected $admlib;
136
137    /**
138     * X server user name
139     *
140     * @var string
141     */
142    protected $wwwuser;
143
144    /**
145     * X server user password
146     *
147     * @var string
148     */
149    protected $wwwpasswd;
150
151    /**
152     * Is X server enabled?
153     *
154     * @var bool
155     */
156    protected $xserver_enabled;
157
158    /**
159     * X server port (defaults to 80)
160     *
161     * @var int
162     */
163    protected $xport;
164
165    /**
166     * DLF REST API port
167     *
168     * @var int
169     */
170    protected $dlfport;
171
172    /**
173     * Statuses considered as available
174     *
175     * @var array
176     */
177    protected $available_statuses;
178
179    /**
180     * List of patron hoe libraries
181     *
182     * @var array
183     */
184    protected $sublibadm;
185
186    /**
187     * If enabled and Xserver is disabled, slower RESTful API is used for
188     * availability check.
189     *
190     * @var bool
191     */
192    protected $quick_availability;
193
194    /**
195     * Is debug mode enabled?
196     *
197     * @var bool
198     */
199    protected $debug_enabled;
200
201    /**
202     * Preferred pickup locations
203     *
204     * @var array
205     */
206    protected $preferredPickUpLocations;
207
208    /**
209     * Patron id used when no specific patron defined
210     *
211     * @var string
212     */
213    protected $defaultPatronId;
214
215    /**
216     * Mapping of z304 address elements in Aleph to getMyProfile attributes
217     *
218     * @var array
219     */
220    protected $addressMappings = null;
221
222    /**
223     * ISO 3166-1 alpha-2 to ISO 3166-1 alpha-3 mapping for
224     * translation in REST DLF API.
225     *
226     * @var array
227     */
228    protected $languages = [];
229
230    /**
231     * Regex for extracting position in queue from status in holdings.
232     *
233     * @var string
234     */
235    protected $queuePositionRegex = '/Waiting in position '
236        . '(?<position>[0-9]+) in queue;/';
237
238    /**
239     * Constructor
240     *
241     * @param \VuFind\Date\Converter $dateConverter Date converter
242     * @param \VuFind\Cache\Manager  $cacheManager  Cache manager (optional)
243     * @param TranslatorInterface    $translator    Translator (optional)
244     */
245    public function __construct(
246        \VuFind\Date\Converter $dateConverter,
247        \VuFind\Cache\Manager $cacheManager = null,
248        TranslatorInterface $translator = null
249    ) {
250        $this->dateConverter = $dateConverter;
251        $this->cacheManager = $cacheManager;
252        $this->translator = $translator;
253    }
254
255    /**
256     * Initialize the driver.
257     *
258     * Validate configuration and perform all resource-intensive tasks needed to
259     * make the driver active.
260     *
261     * @throws ILSException
262     * @return void
263     */
264    public function init()
265    {
266        // Validate config
267        $required = [
268            'host', 'bib', 'useradm', 'admlib', 'dlfport', 'available_statuses',
269        ];
270        foreach ($required as $current) {
271            if (!isset($this->config['Catalog'][$current])) {
272                throw new ILSException("Missing Catalog/{$current} config setting.");
273            }
274        }
275        if (!isset($this->config['sublibadm'])) {
276            throw new ILSException('Missing sublibadm config setting.');
277        }
278
279        // Process config
280        $this->host = $this->config['Catalog']['host'];
281        $this->bib = explode(',', $this->config['Catalog']['bib']);
282        $this->useradm = $this->config['Catalog']['useradm'];
283        $this->admlib = $this->config['Catalog']['admlib'];
284        if (
285            isset($this->config['Catalog']['wwwuser'])
286            && isset($this->config['Catalog']['wwwpasswd'])
287        ) {
288            $this->wwwuser = $this->config['Catalog']['wwwuser'];
289            $this->wwwpasswd = $this->config['Catalog']['wwwpasswd'];
290            $this->xserver_enabled = true;
291            $this->xport = $this->config['Catalog']['xport'] ?? 80;
292        } else {
293            $this->xserver_enabled = false;
294        }
295        $this->dlfport = $this->config['Catalog']['dlfport'];
296        if (isset($this->config['Catalog']['dlfbaseurl'])) {
297            $this->dlfbaseurl = $this->config['Catalog']['dlfbaseurl'];
298        }
299        $this->sublibadm = $this->config['sublibadm'];
300        $this->available_statuses
301            = explode(',', $this->config['Catalog']['available_statuses']);
302        $this->quick_availability
303            = $this->config['Catalog']['quick_availability'] ?? false;
304        $this->debug_enabled = $this->config['Catalog']['debug'] ?? false;
305        if (
306            isset($this->config['util']['tab40'])
307            && isset($this->config['util']['tab15'])
308            && isset($this->config['util']['tab_sub_library'])
309        ) {
310            $cache = null;
311            if (
312                isset($this->config['Cache']['type'])
313                && null !== $this->cacheManager
314            ) {
315                $cache = $this->cacheManager
316                    ->getCache($this->config['Cache']['type']);
317                $this->alephTranslator = $cache->getItem('alephTranslator');
318            }
319            if ($this->alephTranslator == false) {
320                $this->alephTranslator = new Aleph\Translator($this->config);
321                if (isset($cache)) {
322                    $cache->setItem('alephTranslator', $this->alephTranslator);
323                }
324            }
325        }
326        if (isset($this->config['Catalog']['preferred_pick_up_locations'])) {
327            $this->preferredPickUpLocations = explode(
328                ',',
329                $this->config['Catalog']['preferred_pick_up_locations']
330            );
331        }
332        if (isset($this->config['Catalog']['default_patron_id'])) {
333            $this->defaultPatronId = $this->config['Catalog']['default_patron_id'];
334        }
335
336        $this->addressMappings = $this->getDefaultAddressMappings();
337
338        if (isset($this->config['AddressMappings'])) {
339            foreach ($this->config['AddressMappings'] as $key => $val) {
340                $this->addressMappings[$key] = $val;
341            }
342        }
343
344        if (isset($this->config['Catalog']['queue_position_regex'])) {
345            $this->queuePositionRegex
346                = $this->config['Catalog']['queue_position_regex'];
347        }
348
349        if (isset($this->config['Languages'])) {
350            foreach ($this->config['Languages'] as $locale => $lang) {
351                $this->languages[$locale] = $lang;
352            }
353        }
354    }
355
356    /**
357     * Return default mapping of z304 address elements in Aleph
358     * to getMyProfile attributes.
359     *
360     * @return array
361     */
362    protected function getDefaultAddressMappings()
363    {
364        return [
365            'fullname' => 'z304-address-1',
366            'address1' => 'z304-address-2',
367            'address2' => 'z304-address-3',
368            'city'     => 'z304-address-4',
369            'zip'      => 'z304-zip',
370            'email'    => 'z304-email-address',
371            'phone'    => 'z304-telephone-1',
372        ];
373    }
374
375    /**
376     * Perform an XServer request.
377     *
378     * @param string $op     Operation
379     * @param array  $params Parameters
380     * @param bool   $auth   Include authentication?
381     *
382     * @return \SimpleXMLElement
383     */
384    protected function doXRequest($op, $params, $auth = false)
385    {
386        if (!$this->xserver_enabled) {
387            throw new \Exception(
388                'Call to doXRequest without X-Server configuration in Aleph.ini'
389            );
390        }
391        $url = "http://$this->host:$this->xport/X?op=$op";
392        $url = $this->appendQueryString($url, $params);
393        if ($auth) {
394            $url = $this->appendQueryString(
395                $url,
396                [
397                    'user_name' => $this->wwwuser,
398                    'user_password' => $this->wwwpasswd,
399                ]
400            );
401        }
402        $result = $this->doHTTPRequest($url);
403        if ($result->error) {
404            if ($this->debug_enabled) {
405                $this->debug(
406                    "XServer error, URL is $url, error message: $result->error."
407                );
408            }
409            throw new ILSException("XServer error: $result->error.");
410        }
411        return $result;
412    }
413
414    /**
415     * Perform a RESTful DLF request.
416     *
417     * @param array  $path_elements URL path elements
418     * @param array  $params        GET parameters (null for none)
419     * @param string $method        HTTP method
420     * @param string $body          HTTP body
421     *
422     * @return \SimpleXMLElement
423     */
424    protected function doRestDLFRequest(
425        $path_elements,
426        $params = null,
427        $method = 'GET',
428        $body = null
429    ) {
430        $path = implode('/', $path_elements);
431        if ($this->dlfbaseurl === null) {
432            $url = "http://$this->host:$this->dlfport/rest-dlf/" . $path;
433        } else {
434            $url = $this->dlfbaseurl . $path;
435        }
436        if ($params == null) {
437            $params = [];
438        }
439        if (!empty($this->languages) && $this->translator != null) {
440            $locale = $this->translator->getLocale();
441            if (isset($this->languages[$locale])) {
442                $params['lang'] = $this->languages[$locale];
443            }
444        }
445        $url = $this->appendQueryString($url, $params);
446        $result = $this->doHTTPRequest($url, $method, $body);
447        $replyCode = (string)$result->{'reply-code'};
448        if ($replyCode != '0000') {
449            $replyText = (string)$result->{'reply-text'};
450            $this->logError(
451                'DLF request failed',
452                [
453                    'url' => $url, 'reply-code' => $replyCode,
454                    'reply-message' => $replyText,
455                ]
456            );
457            $ex = new Aleph\RestfulException($replyText, $replyCode);
458            $ex->setXmlResponse($result);
459            throw $ex;
460        }
461        return $result;
462    }
463
464    /**
465     * Add values to an HTTP query string.
466     *
467     * @param string $url    URL so far
468     * @param array  $params Parameters to add
469     *
470     * @return string
471     */
472    protected function appendQueryString($url, $params)
473    {
474        $sep = (!str_contains($url, '?')) ? '?' : '&';
475        if ($params != null) {
476            foreach ($params as $key => $value) {
477                $url .= $sep . $key . '=' . urlencode($value);
478                $sep = '&';
479            }
480        }
481        return $url;
482    }
483
484    /**
485     * Perform an HTTP request.
486     *
487     * @param string $url    URL of request
488     * @param string $method HTTP method
489     * @param string $body   HTTP body (null for none)
490     *
491     * @return \SimpleXMLElement
492     */
493    protected function doHTTPRequest($url, $method = 'GET', $body = null)
494    {
495        if ($this->debug_enabled) {
496            $this->debug("URL: '$url'");
497        }
498
499        $result = null;
500        try {
501            $client = $this->httpService->createClient($url);
502            $client->setMethod($method);
503            if ($body != null) {
504                $client->setRawBody($body);
505            }
506            $result = $client->send();
507        } catch (\Exception $e) {
508            $this->throwAsIlsException($e);
509        }
510        if (!$result->isSuccess()) {
511            throw new ILSException('HTTP error');
512        }
513        $answer = $result->getBody();
514        if ($this->debug_enabled) {
515            $this->debug("url: $url response: $answer");
516        }
517        $answer = str_replace('xmlns=', 'ns=', $answer);
518        $result = @simplexml_load_string($answer);
519        if (!$result) {
520            if ($this->debug_enabled) {
521                $this->debug("XML is not valid, URL: $url");
522            }
523            throw new ILSException(
524                "XML is not valid, URL: $url method: $method answer: $answer."
525            );
526        }
527        return $result;
528    }
529
530    /**
531     * Convert an ID string into an array of bibliographic base and ID within
532     * the base.
533     *
534     * @param string $id ID to parse.
535     *
536     * @return array
537     */
538    protected function parseId($id)
539    {
540        $result = null;
541        if (str_contains($id, self::RECORD_ID_BASE_SEPARATOR)) {
542            $result = explode(self::RECORD_ID_BASE_SEPARATOR, $id);
543            $base = $result[0];
544            if (!in_array($base, $this->bib)) {
545                throw new \Exception("Unknown library base '$base'");
546            }
547        } elseif (count($this->bib) == 1) {
548            $result = [$this->bib[0], $id];
549        } else {
550            throw new \Exception(
551                "Invalid record identifier '$id"
552                . 'without library base'
553            );
554        }
555        return $result;
556    }
557
558    /**
559     * Get Status
560     *
561     * This is responsible for retrieving the status information of a certain
562     * record.
563     *
564     * @param string $id The record id to retrieve the holdings for
565     *
566     * @throws ILSException
567     * @return mixed     On success, an associative array with the following keys:
568     * id, availability (boolean), status, location, reserve, callnumber.
569     */
570    public function getStatus($id)
571    {
572        $statuses = $this->getHolding($id);
573        foreach ($statuses as &$status) {
574            $status['status']
575                = ($status['availability'] == 1) ? 'available' : 'unavailable';
576        }
577        return $statuses;
578    }
579
580    /**
581     * Support method for getStatuses -- load ID information from a particular
582     * bibliographic library.
583     *
584     * @param string $bib Library to search
585     * @param array  $ids IDs to search within library
586     *
587     * @return array
588     *
589     * Description of AVA tag:
590     * http://igelu.org/wp-content/uploads/2011/09/Staff-vs-Public-Data-views.pdf
591     * (page 28)
592     *
593     * a  ADM code - Institution Code
594     * b  Sublibrary code - Library Code
595     * c  Collection (first found) - Collection Code
596     * d  Call number (first found)
597     * e  Availability status  - If it is on loan (it has a Z36), if it is on hold
598     *    shelf (it has  Z37=S) or if it has a processing status.
599     * f  Number of items (for entire sublibrary)
600     * g  Number of unavailable loans
601     * h  Multi-volume flag (Y/N) If first Z30-ENUMERATION-A is not blank or 0, then
602     *    the flag=Y, otherwise the flag=N.
603     * i  Number of loans (for ranking/sorting)
604     * j  Collection code
605     */
606    public function getStatusesX($bib, $ids)
607    {
608        $doc_nums = '';
609        $sep = '';
610        foreach ($ids as $id) {
611            $doc_nums .= $sep . $id;
612            $sep = ',';
613        }
614        $xml = $this->doXRequest(
615            'publish_avail',
616            ['library' => $bib, 'doc_num' => $doc_nums],
617            false
618        );
619        $holding = [];
620        foreach ($xml->xpath('/publish-avail/OAI-PMH') as $rec) {
621            $identifier = $rec->xpath('.//identifier/text()');
622            $id = ((count($this->bib) > 1) ? $bib . '-' : '')
623                . substr($identifier[0], strrpos($identifier[0], ':') + 1);
624            $temp = [];
625            foreach ($rec->xpath(".//datafield[@tag='AVA']") as $datafield) {
626                $status = $datafield->xpath('./subfield[@code="e"]/text()');
627                $location = $datafield->xpath('./subfield[@code="a"]/text()');
628                $signature = $datafield->xpath('./subfield[@code="d"]/text()');
629                $availability
630                    = ($status[0] == 'available' || $status[0] == 'check_holdings');
631                $reserve = true;
632                $temp[] = [
633                    'id' => $id,
634                    'availability' => $availability,
635                    'status' => (string)$status[0],
636                    'location' => (string)$location[0],
637                    'signature' => (string)$signature[0],
638                    'reserve' => $reserve,
639                    'callnumber' => (string)$signature[0],
640                ];
641            }
642            $holding[] = $temp;
643        }
644        return $holding;
645    }
646
647    /**
648     * Get Statuses
649     *
650     * This is responsible for retrieving the status information for a
651     * collection of records.
652     *
653     * @param array $idList The array of record ids to retrieve the status for
654     *
655     * @throws ILSException
656     * @return array        An array of getStatus() return values on success.
657     */
658    public function getStatuses($idList)
659    {
660        if (!$this->xserver_enabled) {
661            if (!$this->quick_availability) {
662                return [];
663            }
664            $result = [];
665            foreach ($idList as $id) {
666                $items = $this->getStatus($id);
667                $result[] = $items;
668            }
669            return $result;
670        }
671        $ids = [];
672        $holdings = [];
673        foreach ($idList as $id) {
674            [$bib, $sys_no] = $this->parseId($id);
675            $ids[$bib][] = $sys_no;
676        }
677        foreach ($ids as $key => $values) {
678            $holds = $this->getStatusesX($key, $values);
679            foreach ($holds as $hold) {
680                $holdings[] = $hold;
681            }
682        }
683        return $holdings;
684    }
685
686    /**
687     * Get Holding
688     *
689     * This is responsible for retrieving the holding information of a certain
690     * record.
691     *
692     * @param string $id      The record id to retrieve the holdings for
693     * @param array  $patron  Patron data
694     * @param array  $options Extra options (not currently used)
695     *
696     * @throws DateException
697     * @throws ILSException
698     * @return array         On success, an associative array with the following
699     * keys: id, availability (boolean), status, location, reserve, callnumber,
700     * duedate, number, barcode.
701     *
702     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
703     */
704    public function getHolding($id, array $patron = null, array $options = [])
705    {
706        $holding = [];
707        [$bib, $sys_no] = $this->parseId($id);
708        $resource = $bib . $sys_no;
709        $params = ['view' => 'full'];
710        if (!empty($patron['id'])) {
711            $params['patron'] = $patron['id'];
712        } elseif (isset($this->defaultPatronId)) {
713            $params['patron'] = $this->defaultPatronId;
714        }
715        $xml = $this->doRestDLFRequest(['record', $resource, 'items'], $params);
716        if (!empty($xml->{'items'})) {
717            $items = $xml->{'items'}->{'item'};
718        } else {
719            $items = [];
720        }
721        foreach ($items as $item) {
722            $item_status         = (string)$item->{'z30-item-status-code'}; // $isc
723            // $ipsc:
724            $item_process_status = (string)$item->{'z30-item-process-status-code'};
725            $sub_library_code    = (string)$item->{'z30-sub-library-code'}; // $slc
726            $z30 = $item->z30;
727            if ($this->alephTranslator) {
728                $item_status = $this->alephTranslator->tab15Translate(
729                    $sub_library_code,
730                    $item_status,
731                    $item_process_status
732                );
733            } else {
734                $item_status = [
735                    'opac'         => 'Y',
736                    'request'      => 'C',
737                    'desc'         => (string)$z30->{'z30-item-status'},
738                    'sub_lib_desc' => (string)$z30->{'z30-sub-library'},
739                ];
740            }
741            if ($item_status['opac'] != 'Y') {
742                continue;
743            }
744            $availability = false;
745            //$reserve = ($item_status['request'] == 'C')?'N':'Y';
746            $collection = (string)$z30->{'z30-collection'};
747            $collection_desc = ['desc' => $collection];
748            if ($this->alephTranslator) {
749                $collection_code = (string)$item->{'z30-collection-code'};
750                $collection_desc = $this->alephTranslator->tab40Translate(
751                    $collection_code,
752                    $sub_library_code
753                );
754            }
755            $requested = false;
756            $duedate = '';
757            $addLink = false;
758            $status = (string)$item->{'status'};
759            if (in_array($status, $this->available_statuses)) {
760                $availability = true;
761            }
762            if ($item_status['request'] == 'Y' && $availability == false) {
763                $addLink = true;
764            }
765            if (!empty($patron)) {
766                $hold_request = $item->xpath('info[@type="HoldRequest"]/@allowed');
767                $addLink = ($hold_request[0] == 'Y');
768            }
769            $matches = [];
770            $dueDateWithStatusRegEx
771                = '/([0-9]*\\/[a-zA-Z0-9]*\\/[0-9]*);([a-zA-Z ]*)/';
772            $dueDateRegEx = '/([0-9]*\\/[a-zA-Z0-9]*\\/[0-9]*)/';
773            if (preg_match($dueDateWithStatusRegEx, $status, $matches)) {
774                $duedate = $this->parseDate($matches[1]);
775                $requested = (trim($matches[2]) == 'Requested');
776            } elseif (preg_match($dueDateRegEx, $status, $matches)) {
777                $duedate = $this->parseDate($matches[1]);
778            } else {
779                $duedate = null;
780            }
781            $item_id = $item->attributes()->href;
782            $item_id = substr($item_id, strrpos($item_id, '/') + 1);
783            $note    = (string)$z30->{'z30-note-opac'};
784            $holding[] = [
785                'id'                => $id,
786                'item_id'           => $item_id,
787                'availability'      => $availability,
788                'status'            => (string)$item_status['desc'],
789                'location'          => $sub_library_code,
790                'reserve'           => 'N',
791                'callnumber'        => (string)$z30->{'z30-call-no'},
792                'duedate'           => (string)$duedate,
793                'number'            => (string)$z30->{'z30-inventory-number'},
794                'barcode'           => (string)$z30->{'z30-barcode'},
795                'description'       => (string)$z30->{'z30-description'},
796                'notes'             => ($note == null) ? null : [$note],
797                'is_holdable'       => true,
798                'addLink'           => $addLink,
799                'holdtype'          => 'hold',
800                /* below are optional attributes*/
801                'collection'        => (string)$collection,
802                'collection_desc'   => (string)$collection_desc['desc'],
803                'callnumber_second' => (string)$z30->{'z30-call-no-2'},
804                'sub_lib_desc'      => (string)$item_status['sub_lib_desc'],
805                'no_of_loans'       => (string)$z30->{'$no_of_loans'},
806                'requested'         => (string)$requested,
807            ];
808        }
809        return $holding;
810    }
811
812    /**
813     * Get Patron Loan History
814     *
815     * @param array $user   The patron array from patronLogin
816     * @param array $params Parameters
817     *
818     * @throws DateException
819     * @throws ILSException
820     * @return array      Array of the patron's historic loans on success.
821     */
822    public function getMyTransactionHistory($user, $params = null)
823    {
824        return $this->getMyTransactions($user, $params, true);
825    }
826
827    /**
828     * Get Patron Transactions
829     *
830     * This is responsible for retrieving all transactions (i.e. checked out items)
831     * by a specific patron.
832     *
833     * @param array   $user    The patron array from patronLogin
834     * @param array   $params  Parameters
835     * @param boolean $history History
836     *
837     * @throws DateException
838     * @throws ILSException
839     * @return array        Array of the patron's transactions on success.
840     */
841    public function getMyTransactions($user, $params = [], $history = false)
842    {
843        $userId = $user['id'];
844
845        $alephParams = [];
846        if ($history) {
847            $alephParams['type'] = 'history';
848        }
849
850        // total count without details is fast
851        $totalCount = count(
852            $this->doRestDLFRequest(
853                ['patron', $userId, 'circulationActions', 'loans'],
854                $alephParams
855            )->xpath('//loan')
856        );
857
858        // with full details and paging
859        $pageSize = $params['limit'] ?? 50;
860        $itemsNoKey = $history ? 'no_loans' : 'noItems';
861        $alephParams += [
862            'view' => 'full',
863            'startPos' => isset($params['page'])
864                ? ($params['page'] - 1) * $pageSize : 0,
865            $itemsNoKey => $pageSize,
866        ];
867
868        $xml = $this->doRestDLFRequest(
869            ['patron', $userId, 'circulationActions', 'loans'],
870            $alephParams
871        );
872
873        $transList = [];
874        foreach ($xml->xpath('//loan') as $item) {
875            $z36 = ($history) ? $item->z36h : $item->z36;
876            $prefix = ($history) ? 'z36h-' : 'z36-';
877            $z13 = $item->z13;
878            $z30 = $item->z30;
879            $group = $item->xpath('@href');
880            $group = substr(strrchr($group[0], '/'), 1);
881            $renew = $item->xpath('@renew');
882
883            $location = (string)$z36->{$prefix . 'pickup_location'};
884            $reqnum = (string)$z36->{$prefix . 'doc-number'}
885                . (string)$z36->{$prefix . 'item-sequence'}
886                . (string)$z36->{$prefix . 'sequence'};
887
888            $due = (string)$z36->{$prefix . 'due-date'};
889            $title = (string)$z13->{'z13-title'};
890            $author = (string)$z13->{'z13-author'};
891            $isbn = (string)$z13->{'z13-isbn-issn'};
892            $barcode = (string)$z30->{'z30-barcode'};
893            // Secondary, Aleph-specific identifier that may be useful for
894            // local customizations
895            $adm_id = (string)$z30->{'z30-doc-number'};
896
897            $transaction = [
898                'id' => $this->barcodeToID($barcode),
899                'adm_id'   => $adm_id,
900                'item_id' => $group,
901                'location' => $location,
902                'title' => $title,
903                'author' => $author,
904                'isbn' => $isbn,
905                'reqnum' => $reqnum,
906                'barcode' => $barcode,
907                'duedate' => $this->parseDate($due),
908                'renewable' => $renew[0] == 'Y',
909            ];
910            if ($history) {
911                $issued = (string)$z36->{$prefix . 'loan-date'};
912                $returned = (string)$z36->{$prefix . 'returned-date'};
913                $transaction['checkoutDate'] = $this->parseDate($issued);
914                $transaction['returnDate'] = $this->parseDate($returned);
915            }
916            $transList[] = $transaction;
917        }
918
919        $key = ($history) ? 'transactions' : 'records';
920
921        return [
922            'count' => $totalCount,
923            $key => $transList,
924        ];
925    }
926
927    /**
928     * Get Renew Details
929     *
930     * In order to renew an item, Voyager requires the patron details and an item
931     * id. This function returns the item id as a string which is then used
932     * as submitted form data in checkedOut.php. This value is then extracted by
933     * the RenewMyItems function.
934     *
935     * @param array $details An array of item data
936     *
937     * @return string Data for use in a form field
938     */
939    public function getRenewDetails($details)
940    {
941        return $details['item_id'];
942    }
943
944    /**
945     * Renew My Items
946     *
947     * Function for attempting to renew a patron's items. The data in
948     * $details['details'] is determined by getRenewDetails().
949     *
950     * @param array $details An array of data required for renewing items
951     * including the Patron ID and an array of renewal IDS
952     *
953     * @return array              An array of renewal information keyed by item ID
954     */
955    public function renewMyItems($details)
956    {
957        $patron = $details['patron'];
958        $result = [];
959        foreach ($details['details'] as $id) {
960            try {
961                $xml = $this->doRestDLFRequest(
962                    [
963                        'patron', $patron['id'], 'circulationActions', 'loans', $id,
964                    ],
965                    null,
966                    'POST',
967                    null
968                );
969                $due = (string)current($xml->xpath('//new-due-date'));
970                $result[$id] = [
971                    'success' => true, 'new_date' => $this->parseDate($due),
972                ];
973            } catch (Aleph\RestfulException $ex) {
974                $result[$id] = [
975                    'success' => false, 'sysMessage' => $ex->getMessage(),
976                ];
977            }
978        }
979        return ['blocks' => false, 'details' => $result];
980    }
981
982    /**
983     * Get Patron Holds
984     *
985     * This is responsible for retrieving all holds by a specific patron.
986     *
987     * @param array $user The patron array from patronLogin
988     *
989     * @throws DateException
990     * @throws ILSException
991     * @return array      Array of the patron's holds on success.
992     */
993    public function getMyHolds($user)
994    {
995        $userId = $user['id'];
996        $holdList = [];
997        $xml = $this->doRestDLFRequest(
998            ['patron', $userId, 'circulationActions', 'requests', 'holds'],
999            ['view' => 'full']
1000        );
1001        foreach ($xml->xpath('//hold-request') as $item) {
1002            $z37 = $item->z37;
1003            $z13 = $item->z13;
1004            $z30 = $item->z30;
1005            $delete = $item->xpath('@delete');
1006            $href = $item->xpath('@href');
1007            $item_id = substr($href[0], strrpos($href[0], '/') + 1);
1008            $type = 'hold';
1009            $location = (string)$z37->{'z37-pickup-location'};
1010            $reqnum = (string)$z37->{'z37-doc-number'}
1011                . (string)$z37->{'z37-item-sequence'}
1012                . (string)$z37->{'z37-sequence'};
1013            $expire = (string)$z37->{'z37-end-request-date'};
1014            $create = (string)$z37->{'z37-open-date'};
1015            $holddate = (string)$z37->{'z37-hold-date'};
1016            $title = (string)$z13->{'z13-title'};
1017            $author = (string)$z13->{'z13-author'};
1018            $isbn = (string)$z13->{'z13-isbn-issn'};
1019            $barcode = (string)$z30->{'z30-barcode'};
1020            // remove superfluous spaces in status
1021            $status = preg_replace("/\s[\s]+/", ' ', $item->status);
1022            $position = null;
1023            // Extract position in the hold queue from item status
1024            if (preg_match($this->queuePositionRegex, $status, $matches)) {
1025                $position = $matches['position'];
1026            }
1027            if ($holddate == '00000000') {
1028                $holddate = null;
1029            } else {
1030                $holddate = $this->parseDate($holddate);
1031            }
1032            $delete = ($delete[0] == 'Y');
1033            // Secondary, Aleph-specific identifier that may be useful for
1034            // local customizations
1035            $adm_id = (string)$z30->{'z30-doc-number'};
1036
1037            $holdList[] = [
1038                'type' => $type,
1039                'item_id' => $item_id,
1040                'adm_id'   => $adm_id,
1041                'location' => $location,
1042                'title' => $title,
1043                'author' => $author,
1044                'isbn' => $isbn,
1045                'reqnum' => $reqnum,
1046                'barcode' => $barcode,
1047                'id' => $this->barcodeToID($barcode),
1048                'expire' => $this->parseDate($expire),
1049                'holddate' => $holddate,
1050                'delete' => $delete,
1051                'create' => $this->parseDate($create),
1052                'status' => $status,
1053                'position' => $position,
1054            ];
1055        }
1056        return $holdList;
1057    }
1058
1059    /**
1060     * Get Cancel Hold Details
1061     *
1062     * @param array $holdDetails A single hold array from getMyHolds
1063     * @param array $patron      Patron information from patronLogin
1064     *
1065     * @return string Data for use in a form field
1066     *
1067     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1068     */
1069    public function getCancelHoldDetails($holdDetails, $patron = [])
1070    {
1071        if ($holdDetails['delete']) {
1072            return $holdDetails['item_id'];
1073        } else {
1074            return '';
1075        }
1076    }
1077
1078    /**
1079     * Cancel Holds
1080     *
1081     * Attempts to Cancel a hold or recall on a particular item. The
1082     * data in $cancelDetails['details'] is determined by getCancelHoldDetails().
1083     *
1084     * @param array $details An array of item and patron data
1085     *
1086     * @return array               An array of data on each request including
1087     * whether or not it was successful and a system message (if available)
1088     */
1089    public function cancelHolds($details)
1090    {
1091        $patron = $details['patron'];
1092        $patronId = $patron['id'];
1093        $count = 0;
1094        $items = [];
1095        foreach ($details['details'] as $id) {
1096            try {
1097                $result = $this->doRestDLFRequest(
1098                    [
1099                        'patron', $patronId, 'circulationActions', 'requests',
1100                        'holds', $id,
1101                    ],
1102                    null,
1103                    'DELETE'
1104                );
1105                $count++;
1106                $items[$id] = ['success' => true, 'status' => 'cancel_hold_ok'];
1107            } catch (Aleph\RestfulException $e) {
1108                $items[$id] = [
1109                    'success' => false,
1110                    'status' => 'cancel_hold_failed',
1111                    'sysMessage' => $e->getMessage(),
1112                ];
1113            }
1114        }
1115        return ['count' => $count, 'items' => $items];
1116    }
1117
1118    /**
1119     * Get Patron Fines
1120     *
1121     * This is responsible for retrieving all fines by a specific patron.
1122     *
1123     * @param array $user The patron array from patronLogin
1124     *
1125     * @throws DateException
1126     * @throws ILSException
1127     * @return mixed      Array of the patron's fines on success.
1128     */
1129    public function getMyFines($user)
1130    {
1131        $finesList = [];
1132
1133        $xml = $this->doRestDLFRequest(
1134            ['patron', $user['id'], 'circulationActions', 'cash'],
1135            ['view' => 'full']
1136        );
1137
1138        foreach ($xml->xpath('//cash') as $item) {
1139            $z31 = $item->z31;
1140            $z13 = $item->z13;
1141            $z30 = $item->z30;
1142            $title = (string)$z13->{'z13-title'};
1143            $description = (string)$z31->{'z31-description'};
1144            $transactiondate = date('d-m-Y', strtotime((string)$z31->{'z31-date'}));
1145            $transactiontype = (string)$z31->{'z31-credit-debit'};
1146            $id = (string)$z13->{'z13-doc-number'};
1147            $barcode = (string)$z30->{'z30-barcode'};
1148            $checkout = (string)$z31->{'z31-date'};
1149            $id = $this->barcodeToID($barcode);
1150            $cachetype = strtolower((string)($item->attributes()->type ?? ''));
1151            $mult = $cachetype == 'debit' ? -100 : 100;
1152            $amount
1153                = (float)(preg_replace("/[\(\)]/", '', (string)$z31->{'z31-sum'}))
1154                * $mult;
1155            $cashref = (string)$z31->{'z31-sequence'};
1156
1157            $finesList["$cashref"]  = [
1158                    'title'   => $title,
1159                    'barcode' => $barcode,
1160                    'amount' => $amount,
1161                    'transactiondate' => $transactiondate,
1162                    'transactiontype' => $transactiontype,
1163                    'checkout' => $this->parseDate($checkout),
1164                    'balance'  => $amount,
1165                    'id'  => $id,
1166                    'printLink' => 'test',
1167                    'fine' => $description,
1168            ];
1169        }
1170        ksort($finesList);
1171        return array_values($finesList);
1172    }
1173
1174    /**
1175     * Get Patron Profile
1176     *
1177     * This is responsible for retrieving the profile for a specific patron.
1178     *
1179     * @param array $user The patron array
1180     *
1181     * @throws ILSException
1182     * @return array      Array of the patron's profile data on success.
1183     */
1184    public function getMyProfile($user)
1185    {
1186        if ($this->xserver_enabled) {
1187            $profile = $this->getMyProfileX($user);
1188        } else {
1189            $profile = $this->getMyProfileDLF($user);
1190        }
1191        $profile['cat_username'] ??= $user['id'];
1192        return $profile;
1193    }
1194
1195    /**
1196     * Get profile information using X-server.
1197     *
1198     * @param array $user The patron array
1199     *
1200     * @throws ILSException
1201     * @return array      Array of the patron's profile data on success.
1202     */
1203    public function getMyProfileX($user)
1204    {
1205        if (!isset($user['college'])) {
1206            $user['college'] = $this->useradm;
1207        }
1208        $xml = $this->doXRequest(
1209            'bor-info',
1210            [
1211                'loans' => 'N', 'cash' => 'N', 'hold' => 'N',
1212                'library' => $user['college'], 'bor_id' => $user['id'],
1213            ],
1214            true
1215        );
1216        $id = (string)$xml->z303->{'z303-id'};
1217        $address1 = (string)$xml->z304->{'z304-address-2'};
1218        $address2 = (string)$xml->z304->{'z304-address-3'};
1219        $zip = (string)$xml->z304->{'z304-zip'};
1220        $phone = (string)$xml->z304->{'z304-telephone'};
1221        $barcode = (string)$xml->z304->{'z304-address-0'};
1222        $group = (string)$xml->z305->{'z305-bor-status'};
1223        $expiry = (string)$xml->z305->{'z305-expiry-date'};
1224        $credit_sum = (string)$xml->z305->{'z305-sum'};
1225        $credit_sign = (string)$xml->z305->{'z305-credit-debit'};
1226        $name = (string)$xml->z303->{'z303-name'};
1227        if (strstr($name, ',')) {
1228            [$lastname, $firstname] = explode(',', $name);
1229        } else {
1230            $lastname = $name;
1231            $firstname = '';
1232        }
1233        if ($credit_sign == null) {
1234            $credit_sign = 'C';
1235        }
1236        $recordList = compact('firstname', 'lastname');
1237        if (isset($user['email'])) {
1238            $recordList['email'] = $user['email'];
1239        }
1240        $recordList['address1'] = $address1;
1241        $recordList['address2'] = $address2;
1242        $recordList['zip'] = $zip;
1243        $recordList['phone'] = $phone;
1244        $recordList['group'] = $group;
1245        $recordList['barcode'] = $barcode;
1246        $recordList['expire'] = $this->parseDate($expiry);
1247        $recordList['credit'] = $expiry;
1248        $recordList['credit_sum'] = $credit_sum;
1249        $recordList['credit_sign'] = $credit_sign;
1250        $recordList['id'] = $id;
1251        return $recordList;
1252    }
1253
1254    /**
1255     * Get profile information using DLF service.
1256     *
1257     * @param array $user The patron array
1258     *
1259     * @throws ILSException
1260     * @return array      Array of the patron's profile data on success.
1261     */
1262    public function getMyProfileDLF($user)
1263    {
1264        $recordList = [];
1265        $xml = $this->doRestDLFRequest(
1266            ['patron', $user['id'], 'patronInformation', 'address']
1267        );
1268        $profile = [];
1269        $profile['id'] = $user['id'];
1270        $profile['cat_username'] = $user['id'];
1271        $address = $xml->xpath('//address-information')[0];
1272        foreach ($this->addressMappings as $key => $value) {
1273            if (!empty($value)) {
1274                $profile[$key] = (string)$address->{$value};
1275            }
1276        }
1277        $fullName = $profile['fullname'];
1278        if (!str_contains($fullName, ',')) {
1279            $profile['lastname'] = $fullName;
1280            $profile['firstname'] = '';
1281        } else {
1282            [$profile['lastname'], $profile['firstname']]
1283                = explode(',', $fullName);
1284        }
1285        $xml = $this->doRestDLFRequest(
1286            ['patron', $user['id'], 'patronStatus', 'registration']
1287        );
1288        $status = $xml->xpath('//institution/z305-bor-status');
1289        $expiry = $xml->xpath('//institution/z305-expiry-date');
1290        $profile['expiration_date'] = $this->parseDate($expiry[0]);
1291        $profile['group'] = $status[0];
1292        return $profile;
1293    }
1294
1295    /**
1296     * Patron Login
1297     *
1298     * This is responsible for authenticating a patron against the catalog.
1299     *
1300     * @param string $user     The patron username
1301     * @param string $password The patron's password
1302     *
1303     * @throws ILSException
1304     * @return mixed          Associative array of patron info on successful login,
1305     * null on unsuccessful login.
1306     */
1307    public function patronLogin($user, $password)
1308    {
1309        if ($password == null) {
1310            $temp = ['id' => $user];
1311            $temp['college'] = $this->useradm;
1312            return $this->getMyProfile($temp);
1313        }
1314        try {
1315            $xml = $this->doXRequest(
1316                'bor-auth',
1317                [
1318                    'library' => $this->useradm, 'bor_id' => $user,
1319                    'verification' => $password,
1320                ],
1321                true
1322            );
1323        } catch (\Exception $ex) {
1324            if (str_contains($ex->getMessage(), 'Error in Verification')) {
1325                return null;
1326            }
1327            $this->throwAsIlsException($ex);
1328        }
1329        $patron = [];
1330        $name = $xml->z303->{'z303-name'};
1331        if (strstr($name, ',')) {
1332            [$lastName, $firstName] = explode(',', $name);
1333        } else {
1334            $lastName = $name;
1335            $firstName = '';
1336        }
1337        $email_addr = $xml->z304->{'z304-email-address'};
1338        $id = $xml->z303->{'z303-id'};
1339        $home_lib = $xml->z303->z303_home_library;
1340        // Default the college to the useradm library and overwrite it if the
1341        // home_lib exists
1342        $patron['college'] = $this->useradm;
1343        if (($home_lib != '') && (array_key_exists("$home_lib", $this->sublibadm))) {
1344            if ($this->sublibadm["$home_lib"] != '') {
1345                $patron['college'] = $this->sublibadm["$home_lib"];
1346            }
1347        }
1348        $patron['id'] = (string)$id;
1349        $patron['barcode'] = (string)$user;
1350        $patron['firstname'] = (string)$firstName;
1351        $patron['lastname'] = (string)$lastName;
1352        $patron['cat_username'] = (string)$user;
1353        $patron['cat_password'] = $password;
1354        $patron['email'] = (string)$email_addr;
1355        $patron['major'] = null;
1356        return $patron;
1357    }
1358
1359    /**
1360     * Support method for placeHold -- get holding info for an item.
1361     *
1362     * @param string $patronId Patron ID
1363     * @param string $id       Bib ID
1364     * @param string $group    Item ID
1365     *
1366     * @return array
1367     */
1368    public function getHoldingInfoForItem($patronId, $id, $group)
1369    {
1370        [$bib, $sys_no] = $this->parseId($id);
1371        $resource = $bib . $sys_no;
1372        $xml = $this->doRestDLFRequest(
1373            ['patron', $patronId, 'record', $resource, 'items', $group]
1374        );
1375        $locations = [];
1376        $part = $xml->xpath('//pickup-locations');
1377        if ($part) {
1378            foreach ($part[0]->children() as $node) {
1379                $arr = $node->attributes();
1380                $code = (string)$arr['code'];
1381                $loc_name = (string)$node;
1382                $locations[$code] = $loc_name;
1383            }
1384        } else {
1385            throw new ILSException('No pickup locations');
1386        }
1387        $requests = 0;
1388        $str = $xml->xpath('//item/queue/text()');
1389        if ($str != null) {
1390            [$requests] = explode(' ', trim($str[0]));
1391        }
1392        $date = $xml->xpath('//last-interest-date/text()');
1393        $date = $date[0];
1394        $date = '' . substr($date, 6, 2) . '.' . substr($date, 4, 2) . '.'
1395            . substr($date, 0, 4);
1396        return [
1397            'pickup-locations' => $locations, 'last-interest-date' => $date,
1398            'order' => $requests + 1,
1399        ];
1400    }
1401
1402    /**
1403     * Get Default "Hold Required By" Date (as Unix timestamp) or null if unsupported
1404     *
1405     * @param array $patron   Patron information returned by the patronLogin method.
1406     * @param array $holdInfo Contains most of the same values passed to
1407     * placeHold, minus the patron data.
1408     *
1409     * @return int|null
1410     */
1411    public function getHoldDefaultRequiredDate($patron, $holdInfo)
1412    {
1413        $details = [];
1414        if ($holdInfo != null) {
1415            $details = $this->getHoldingInfoForItem(
1416                $patron['id'],
1417                $holdInfo['id'],
1418                $holdInfo['item_id']
1419            );
1420        }
1421        if (isset($details['last-interest-date'])) {
1422            try {
1423                return $this->dateConverter
1424                    ->convert('d.m.Y', 'U', $details['last-interest-date']);
1425            } catch (DateException $e) {
1426                // If we couldn't convert the date, fail gracefully.
1427                $this->debug(
1428                    'Could not convert date: ' . $details['last-interest-date']
1429                );
1430            }
1431        }
1432        return null;
1433    }
1434
1435    /**
1436     * Place Hold
1437     *
1438     * Attempts to place a hold or recall on a particular item and returns
1439     * an array with result details or throws an exception on failure of support
1440     * classes
1441     *
1442     * @param array $details An array of item and patron data
1443     *
1444     * @throws ILSException
1445     * @return mixed An array of data on the request including
1446     * whether or not it was successful and a system message (if available)
1447     */
1448    public function placeHold($details)
1449    {
1450        [$bib, $sys_no] = $this->parseId($details['id']);
1451        $recordId = $bib . $sys_no;
1452        $itemId = $details['item_id'];
1453        $patron = $details['patron'];
1454        $pickupLocation = $details['pickUpLocation'];
1455        if (!$pickupLocation) {
1456            $pickupLocation = $this->getDefaultPickUpLocation($patron, $details);
1457        }
1458        $comment = $details['comment'];
1459        if (strlen($comment) <= 50) {
1460            $comment1 = $comment;
1461            $comment2 = null;
1462        } else {
1463            $comment1 = substr($comment, 0, 50);
1464            $comment2 = substr($comment, 50, 50);
1465        }
1466        try {
1467            $requiredBy = $this->dateConverter
1468                ->convertFromDisplayDate('Ymd', $details['requiredBy']);
1469        } catch (DateException $de) {
1470            return [
1471                'success'    => false,
1472                'sysMessage' => 'hold_date_invalid',
1473            ];
1474        }
1475        $patronId = $patron['id'];
1476        $body = new \SimpleXMLElement(
1477            '<?xml version="1.0" encoding="UTF-8"?>'
1478            . '<hold-request-parameters></hold-request-parameters>'
1479        );
1480        $body->addChild('pickup-location', $pickupLocation);
1481        $body->addChild('last-interest-date', $requiredBy);
1482        $body->addChild('note-1', $comment1);
1483        if (isset($comment2)) {
1484            $body->addChild('note-2', $comment2);
1485        }
1486        $body = 'post_xml=' . $body->asXML();
1487        try {
1488            $this->doRestDLFRequest(
1489                [
1490                    'patron', $patronId, 'record', $recordId, 'items', $itemId,
1491                    'hold',
1492                ],
1493                null,
1494                'PUT',
1495                $body
1496            );
1497        } catch (Aleph\RestfulException $exception) {
1498            $message = $exception->getMessage();
1499            $note = $exception->getXmlResponse()
1500                ->xpath('/put-item-hold/create-hold/note[@type="error"]');
1501            $note = $note[0];
1502            return [
1503                'success' => false,
1504                'sysMessage' => "$message ($note)",
1505            ];
1506        }
1507        return ['success' => true];
1508    }
1509
1510    /**
1511     * Convert a barcode to an item ID.
1512     *
1513     * @param string $bar Barcode
1514     *
1515     * @return string|null
1516     */
1517    public function barcodeToID($bar)
1518    {
1519        if (!$this->xserver_enabled) {
1520            return null;
1521        }
1522        foreach ($this->bib as $base) {
1523            try {
1524                $xml = $this->doXRequest(
1525                    'find',
1526                    ['base' => $base, 'request' => "BAR=$bar"],
1527                    false
1528                );
1529                $docs = (int)$xml->{'no_records'};
1530                if ($docs == 1) {
1531                    $set = (string)$xml->{'set_number'};
1532                    $result = $this->doXRequest(
1533                        'present',
1534                        ['set_number' => $set, 'set_entry' => '1'],
1535                        false
1536                    );
1537                    $id = $result->xpath('//doc_number/text()');
1538                    $idString = (string)$id[0];
1539                    if (count($this->bib) == 1) {
1540                        return $idString;
1541                    } else {
1542                        return $base . '-' . $idString;
1543                    }
1544                }
1545            } catch (\Exception $ex) {
1546            }
1547        }
1548        throw new ILSException('barcode not found');
1549    }
1550
1551    /**
1552     * Parse a date.
1553     *
1554     * @param string $date Date to parse
1555     *
1556     * @return string
1557     */
1558    public function parseDate($date)
1559    {
1560        if ($date == null || $date == '') {
1561            return '';
1562        } elseif (preg_match('/^[0-9]{8}$/', $date) === 1) { // 20120725
1563            return $this->dateConverter->convertToDisplayDate('Ynd', $date);
1564        } elseif (preg_match("/^[0-9]+\/[A-Za-z]{3}\/[0-9]{4}$/", $date) === 1) {
1565            // 13/jan/2012
1566            return $this->dateConverter->convertToDisplayDate('d/M/Y', $date);
1567        } elseif (preg_match("/^[0-9]+\/[0-9]+\/[0-9]{4}$/", $date) === 1) {
1568            // 13/7/2012
1569            return $this->dateConverter->convertToDisplayDate('d/m/Y', $date);
1570        } elseif (preg_match("/^[0-9]+\/[0-9]+\/[0-9]{2}$/", $date) === 1) {
1571            // 13/7/12
1572            return $this->dateConverter->convertToDisplayDate('d/m/y', $date);
1573        } else {
1574            throw new \Exception("Invalid date: $date");
1575        }
1576    }
1577
1578    /**
1579     * Helper method to determine whether or not a certain method can be
1580     * called on this driver. Required method for any smart drivers.
1581     *
1582     * @param string $method The name of the called method.
1583     * @param array  $params Array of passed parameters
1584     *
1585     * @return bool True if the method can be called with the given parameters,
1586     * false otherwise.
1587     *
1588     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1589     */
1590    public function supportsMethod($method, $params)
1591    {
1592        // Loan history is only available if properly configured
1593        if ($method == 'getMyTransactionHistory') {
1594            return !empty($this->config['TransactionHistory']['enabled']);
1595        }
1596        return is_callable([$this, $method]);
1597    }
1598
1599    /**
1600     * Public Function which retrieves historic loan, renew, hold and cancel
1601     * settings from the driver ini file.
1602     *
1603     * @param string $func   The name of the feature to be checked
1604     * @param array  $params Optional feature-specific parameters (array)
1605     *
1606     * @return array An array with key-value pairs.
1607     *
1608     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1609     */
1610    public function getConfig($func, $params = [])
1611    {
1612        if ($func == 'Holds') {
1613            $holdsConfig = $this->config['Holds'] ?? [];
1614            $defaults = [
1615                'HMACKeys' => 'id:item_id',
1616                'extraHoldFields' => 'comments:requiredByDate:pickUpLocation',
1617                'defaultRequiredDate' => '0:1:0',
1618            ];
1619            return $holdsConfig + $defaults;
1620        } elseif ('getMyTransactionHistory' === $func) {
1621            if (empty($this->config['TransactionHistory']['enabled'])) {
1622                return false;
1623            }
1624            return [
1625                'max_results' => 10000,
1626            ];
1627        } else {
1628            return [];
1629        }
1630    }
1631
1632    /**
1633     * Get Pick Up Locations
1634     *
1635     * This is responsible for getting a list of valid library locations for
1636     * holds / recall retrieval
1637     *
1638     * @param array $patron   Patron information returned by the patronLogin method.
1639     * @param array $holdInfo Optional array, only passed in when getting a list
1640     * in the context of placing or editing a hold. When placing a hold, it contains
1641     * most of the same values passed to placeHold, minus the patron data. When
1642     * editing a hold it contains all the hold information returned by getMyHolds.
1643     * May be used to limit the pickup options or may be ignored. The driver must
1644     * not add new options to the return array based on this data or other areas of
1645     * VuFind may behave incorrectly.
1646     *
1647     * @throws ILSException
1648     * @return array        An array of associative arrays with locationID and
1649     * locationDisplay keys
1650     */
1651    public function getPickUpLocations($patron, $holdInfo = null)
1652    {
1653        $pickupLocations = [];
1654        if ($holdInfo != null) {
1655            $details = $this->getHoldingInfoForItem(
1656                $patron['id'],
1657                $holdInfo['id'],
1658                $holdInfo['item_id']
1659            );
1660            foreach ($details['pickup-locations'] as $key => $value) {
1661                $pickupLocations[] = [
1662                    'locationID' => $key,
1663                    'locationDisplay' => $value,
1664                ];
1665            }
1666        } else {
1667            $default = $this->getDefaultPickUpLocation($patron);
1668            if (!empty($default)) {
1669                $pickupLocations[] = [
1670                    'locationID' => $default,
1671                    'locationDisplay' => $default,
1672                ];
1673            }
1674        }
1675        return $pickupLocations;
1676    }
1677
1678    /**
1679     * Get Default Pick Up Location
1680     *
1681     * Returns the default pick up location set in VoyagerRestful.ini
1682     *
1683     * @param array $patron   Patron information returned by the patronLogin method.
1684     * @param array $holdInfo Optional array, only passed in when getting a list
1685     * in the context of placing a hold; contains most of the same values passed to
1686     * placeHold, minus the patron data. May be used to limit the pickup options
1687     * or may be ignored.
1688     *
1689     * @return string       The default pickup location for the patron.
1690     */
1691    public function getDefaultPickUpLocation($patron, $holdInfo = null)
1692    {
1693        if ($holdInfo != null) {
1694            $details = $this->getHoldingInfoForItem(
1695                $patron['id'],
1696                $holdInfo['id'],
1697                $holdInfo['item_id']
1698            );
1699            $pickupLocations = $details['pickup-locations'];
1700            if (isset($this->preferredPickUpLocations)) {
1701                foreach (array_keys($details['pickup-locations']) as $locationID) {
1702                    if (in_array($locationID, $this->preferredPickUpLocations)) {
1703                        return $locationID;
1704                    }
1705                }
1706            }
1707            // nothing found or preferredPickUpLocations is empty? Return the first
1708            // locationId in pickupLocations array
1709            return array_key_first($pickupLocations);
1710        } elseif (isset($this->preferredPickUpLocations)) {
1711            return $this->preferredPickUpLocations[0];
1712        } else {
1713            throw new ILSException(
1714                'Missing Catalog/preferredPickUpLocations config setting.'
1715            );
1716        }
1717    }
1718
1719    /**
1720     * Get Purchase History
1721     *
1722     * This is responsible for retrieving the acquisitions history data for the
1723     * specific record (usually recently received issues of a serial).
1724     *
1725     * @param string $id The record id to retrieve the info for
1726     *
1727     * @throws ILSException
1728     * @return array     An array with the acquisitions data on success.
1729     *
1730     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1731     */
1732    public function getPurchaseHistory($id)
1733    {
1734        // TODO
1735        return [];
1736    }
1737
1738    /**
1739     * Get New Items
1740     *
1741     * Retrieve the IDs of items recently added to the catalog.
1742     *
1743     * @param int $page    Page number of results to retrieve (counting starts at 1)
1744     * @param int $limit   The size of each page of results to retrieve
1745     * @param int $daysOld The maximum age of records to retrieve in days (max. 30)
1746     * @param int $fundId  optional fund ID to use for limiting results (use a value
1747     * returned by getFunds, or exclude for no limit); note that "fund" may be a
1748     * misnomer - if funds are not an appropriate way to limit your new item
1749     * results, you can return a different set of values from getFunds. The
1750     * important thing is that this parameter supports an ID returned by getFunds,
1751     * whatever that may mean.
1752     *
1753     * @throws ILSException
1754     * @return array       Associative array with 'count' and 'results' keys
1755     *
1756     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1757     */
1758    public function getNewItems($page, $limit, $daysOld, $fundId = null)
1759    {
1760        // TODO
1761        $items = [];
1762        return $items;
1763    }
1764
1765    /**
1766     * Get Departments
1767     *
1768     * Obtain a list of departments for use in limiting the reserves list.
1769     *
1770     * @throws ILSException
1771     * @return array An associative array with key = dept. ID, value = dept. name.
1772     */
1773    public function getDepartments()
1774    {
1775        // TODO
1776        return [];
1777    }
1778
1779    /**
1780     * Get Instructors
1781     *
1782     * Obtain a list of instructors for use in limiting the reserves list.
1783     *
1784     * @throws ILSException
1785     * @return array An associative array with key = ID, value = name.
1786     */
1787    public function getInstructors()
1788    {
1789        // TODO
1790        return [];
1791    }
1792
1793    /**
1794     * Get Courses
1795     *
1796     * Obtain a list of courses for use in limiting the reserves list.
1797     *
1798     * @throws ILSException
1799     * @return array An associative array with key = ID, value = name.
1800     */
1801    public function getCourses()
1802    {
1803        // TODO
1804        return [];
1805    }
1806
1807    /**
1808     * Find Reserves
1809     *
1810     * Obtain information on course reserves.
1811     *
1812     * @param string $course ID from getCourses (empty string to match all)
1813     * @param string $inst   ID from getInstructors (empty string to match all)
1814     * @param string $dept   ID from getDepartments (empty string to match all)
1815     *
1816     * @throws ILSException
1817     * @return array An array of associative arrays representing reserve items.
1818     *
1819     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1820     */
1821    public function findReserves($course, $inst, $dept)
1822    {
1823        // TODO
1824        return [];
1825    }
1826}