Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.28% covered (success)
99.28%
416 / 419
93.18% covered (success)
93.18%
41 / 44
CRAP
0.00% covered (danger)
0.00%
0 / 1
MultiBackend
99.28% covered (success)
99.28%
416 / 419
93.18% covered (success)
93.18%
41 / 44
152
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 init
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getStatus
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getStatuses
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
6
 getHolding
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 getPurchaseHistory
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getLoginDrivers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultLoginDriver
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getNewItems
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getDepartments
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getInstructors
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getCourses
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 findReserves
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getMyProfile
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getMyHolds
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getMyStorageRetrievalRequests
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 checkRequestIsValid
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 checkStorageRetrievalRequestIsValid
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 getPickUpLocations
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
4.00
 getDefaultPickUpLocation
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 getRequestGroups
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 getDefaultRequestGroup
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 placeHold
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getCancelHoldDetails
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 placeStorageRetrievalRequest
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 checkILLRequestIsValid
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 getILLPickupLibraries
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 getILLPickupLocations
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 placeILLRequest
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getMyILLRequests
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 getRequestBlocks
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getAccountBlocks
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getConfig
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
 supportsMethod
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
7
 __call
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLocalId
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getSource
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getSourceForMethod
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getSourceFromParams
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
12
 getDriver
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 addIdPrefixes
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
10.02
 stripIdPrefixes
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
13
 driverSupportsSource
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 callMethodIfSupported
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2
3/**
4 * Multiple Backend Driver.
5 *
6 * PHP version 8
7 *
8 * Copyright (C) The National Library of Finland 2012-2021.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  ILSdrivers
25 * @author   Ere Maijala <ere.maijala@helsinki.fi>
26 * @author   Demian Katz <demian.katz@villanova.edu>
27 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
28 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
29 */
30
31namespace VuFind\ILS\Driver;
32
33use VuFind\Exception\ILS as ILSException;
34
35use function call_user_func_array;
36use function func_get_args;
37use function in_array;
38use function is_array;
39use function is_callable;
40use function is_int;
41use function is_string;
42use function strlen;
43
44/**
45 * Multiple Backend Driver.
46 *
47 * This driver allows to use multiple backends determined by a record id or
48 * user id prefix (e.g. source.12345).
49 *
50 * @category VuFind
51 * @package  ILSdrivers
52 * @author   Ere Maijala <ere.maijala@helsinki.fi>
53 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
54 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
55 */
56class MultiBackend extends AbstractMultiDriver
57{
58    use \VuFind\Log\LoggerAwareTrait {
59        logError as error;
60    }
61
62    /**
63     * ID fields in holds
64     */
65    public const HOLD_ID_FIELDS = ['id', 'item_id', 'cat_username'];
66
67    /**
68     * The default driver to use
69     *
70     * @var string
71     */
72    protected $defaultDriver;
73
74    /**
75     * ILS authenticator
76     *
77     * @var \VuFind\Auth\ILSAuthenticator
78     */
79    protected $ilsAuth;
80
81    /**
82     * An array of methods that should determine source from a specific parameter
83     * field
84     *
85     * @var array
86     */
87    protected $sourceCheckFields = [
88        'cancelHolds' => 'cat_username',
89        'cancelILLRequests' => 'cat_username',
90        'cancelStorageRetrievalRequests' => 'cat_username',
91        'changePassword' => 'cat_username',
92        'getCancelHoldDetails' => 'cat_username',
93        'getCancelILLRequestDetails' => 'cat_username',
94        'getCancelStorageRetrievalRequestDetails' => 'cat_username',
95        'getMyFines' => 'cat_username',
96        'getMyProfile' => 'cat_username',
97        'getMyTransactionHistory' => 'cat_username',
98        'getMyTransactions' => 'cat_username',
99        'renewMyItems' => 'cat_username',
100    ];
101
102    /**
103     * Methods that don't have parameters that allow the correct source to be
104     * determined. These methods are only supported for the default driver.
105     */
106    protected $methodsWithNoSourceSpecificParameters = [
107        'findReserves',
108        'getCourses',
109        'getDepartments',
110        'getFunds',
111        'getInstructors',
112        'getNewItems',
113        'getOfflineMode',
114        'getSuppressedAuthorityRecords',
115        'getSuppressedRecords',
116        'loginIsHidden',
117    ];
118
119    /**
120     * Constructor
121     *
122     * @param \VuFind\Config\PluginManager  $configLoader Configuration loader
123     * @param \VuFind\Auth\ILSAuthenticator $ilsAuth      ILS authenticator
124     * @param PluginManager                 $dm           ILS driver manager
125     */
126    public function __construct(
127        \VuFind\Config\PluginManager $configLoader,
128        \VuFind\Auth\ILSAuthenticator $ilsAuth,
129        PluginManager $dm
130    ) {
131        parent::__construct($configLoader, $dm);
132        $this->ilsAuth = $ilsAuth;
133    }
134
135    /**
136     * Initialize the driver.
137     *
138     * Validate configuration and perform all resource-intensive tasks needed to
139     * make the driver active.
140     *
141     * @throws ILSException
142     * @return void
143     */
144    public function init()
145    {
146        parent::init();
147        $this->defaultDriver = $this->config['General']['default_driver'] ?? null;
148    }
149
150    /**
151     * Get Status
152     *
153     * This is responsible for retrieving the status information of a certain
154     * record.
155     *
156     * @param string $id The record id to retrieve the holdings for
157     *
158     * @throws ILSException
159     * @return mixed     On success, an associative array with the following keys:
160     * id, availability (boolean), status, location, reserve, callnumber.
161     */
162    public function getStatus($id)
163    {
164        $source = $this->getSource($id);
165        if ($driver = $this->getDriver($source)) {
166            $status = $driver->getStatus($this->getLocalId($id));
167            return $this->addIdPrefixes($status, $source);
168        }
169        // Return an empty array if driver is not available; id can point to an ILS
170        // that's not currently configured.
171        return [];
172    }
173
174    /**
175     * Get Statuses
176     *
177     * This is responsible for retrieving the status information for a
178     * collection of records.
179     *
180     * @param array $ids The array of record ids to retrieve the status for
181     *
182     * @throws ILSException
183     * @return array     An array of getStatus() return values on success.
184     */
185    public function getStatuses($ids)
186    {
187        // Group records by source and request statuses from the drivers
188        $grouped = [];
189        foreach ($ids as $id) {
190            $source = $this->getSource($id);
191            if (!isset($grouped[$source])) {
192                $driver = $this->getDriver($source);
193                $grouped[$source] = [
194                    'driver' => $driver,
195                    'ids' => [],
196                ];
197            }
198            $grouped[$source]['ids'][] = $id;
199        }
200
201        // Process each group
202        $results = [];
203        foreach ($grouped as $source => $current) {
204            // Get statuses only if a driver is configured for this source
205            if ($current['driver']) {
206                $localIds = array_map(
207                    function ($id) {
208                        return $this->getLocalId($id);
209                    },
210                    $current['ids']
211                );
212                try {
213                    $statuses = $current['driver']->getStatuses($localIds);
214                } catch (ILSException $e) {
215                    $statuses = array_map(
216                        function ($id) {
217                            return [
218                                ['id' => $id, 'error' => 'An error has occurred'],
219                            ];
220                        },
221                        $localIds
222                    );
223                }
224                $statuses = array_map(
225                    function ($status) use ($source) {
226                        return $this->addIdPrefixes($status, $source);
227                    },
228                    $statuses
229                );
230                $results = array_merge($results, $statuses);
231            }
232        }
233        return $results;
234    }
235
236    /**
237     * Get Holding
238     *
239     * This is responsible for retrieving the holding information of a certain
240     * record.
241     *
242     * @param string $id      The record id to retrieve the holdings for
243     * @param array  $patron  Patron data
244     * @param array  $options Extra options (not currently used)
245     *
246     * @return array         On success, an associative array with the following
247     * keys: id, availability (boolean), status, location, reserve, callnumber,
248     * duedate, number, barcode.
249     *
250     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
251     */
252    public function getHolding($id, array $patron = null, array $options = [])
253    {
254        $source = $this->getSource($id);
255        if ($driver = $this->getDriver($source)) {
256            // If the patron belongs to another source, just pass on an empty array
257            // to indicate that the patron has logged in but is not available for the
258            // current catalog.
259            if (
260                $patron
261                && !$this->driverSupportsSource($source, $patron['cat_username'])
262            ) {
263                $patron = [];
264            }
265            $holdings = $driver->getHolding(
266                $this->getLocalId($id),
267                $this->stripIdPrefixes($patron, $source),
268                $options
269            );
270            return $this->addIdPrefixes($holdings, $source);
271        }
272        // Return an empty array if driver is not available; id can point to an ILS
273        // that's not currently configured.
274        return [];
275    }
276
277    /**
278     * Get Purchase History
279     *
280     * This is responsible for retrieving the acquisitions history data for the
281     * specific record (usually recently received issues of a serial).
282     *
283     * @param string $id The record id to retrieve the info for
284     *
285     * @throws ILSException
286     * @return array     An array with the acquisitions data on success.
287     */
288    public function getPurchaseHistory($id)
289    {
290        $source = $this->getSource($id);
291        if ($driver = $this->getDriver($source)) {
292            return $driver->getPurchaseHistory($this->getLocalId($id));
293        }
294        // Return an empty array if driver is not available; id can point to an ILS
295        // that's not currently configured.
296        return [];
297    }
298
299    /**
300     * Get available login targets (drivers enabled for login)
301     *
302     * @return string[] Source ID's
303     */
304    public function getLoginDrivers()
305    {
306        return $this->config['Login']['drivers'] ?? [];
307    }
308
309    /**
310     * Get default login driver
311     *
312     * @return string Default login driver or empty string
313     */
314    public function getDefaultLoginDriver()
315    {
316        if (isset($this->config['Login']['default_driver'])) {
317            return $this->config['Login']['default_driver'];
318        }
319        $drivers = $this->getLoginDrivers();
320        if ($drivers) {
321            return $drivers[0];
322        }
323        return '';
324    }
325
326    /**
327     * Get New Items
328     *
329     * Retrieve the IDs of items recently added to the catalog.
330     *
331     * @param int $page    Page number of results to retrieve (counting starts at 1)
332     * @param int $limit   The size of each page of results to retrieve
333     * @param int $daysOld The maximum age of records to retrieve in days (max. 30)
334     * @param int $fundId  optional fund ID to use for limiting results (use a value
335     * returned by getFunds, or exclude for no limit); note that "fund" may be a
336     * misnomer - if funds are not an appropriate way to limit your new item
337     * results, you can return a different set of values from getFunds. The
338     * important thing is that this parameter supports an ID returned by getFunds,
339     * whatever that may mean.
340     *
341     * @return array       Associative array with 'count' and 'results' keys
342     */
343    public function getNewItems($page, $limit, $daysOld, $fundId = null)
344    {
345        if ($driver = $this->getDriver($this->defaultDriver)) {
346            $result = $driver->getNewItems($page, $limit, $daysOld, $fundId);
347            if (isset($result['results'])) {
348                $result['results']
349                    = $this->addIdPrefixes($result['results'], $this->defaultDriver);
350            }
351            return $result;
352        }
353        throw new ILSException('No suitable backend driver found');
354    }
355
356    /**
357     * Get Departments
358     *
359     * Obtain a list of departments for use in limiting the reserves list.
360     *
361     * @return array An associative array with key = dept. ID, value = dept. name.
362     */
363    public function getDepartments()
364    {
365        if ($driver = $this->getDriver($this->defaultDriver)) {
366            return $driver->getDepartments();
367        }
368        throw new ILSException('No suitable backend driver found');
369    }
370
371    /**
372     * Get Instructors
373     *
374     * Obtain a list of instructors for use in limiting the reserves list.
375     *
376     * @return array An associative array with key = ID, value = name.
377     */
378    public function getInstructors()
379    {
380        if ($driver = $this->getDriver($this->defaultDriver)) {
381            return $driver->getInstructors();
382        }
383        throw new ILSException('No suitable backend driver found');
384    }
385
386    /**
387     * Get Courses
388     *
389     * Obtain a list of courses for use in limiting the reserves list.
390     *
391     * @return array An associative array with key = ID, value = name.
392     */
393    public function getCourses()
394    {
395        if ($driver = $this->getDriver($this->defaultDriver)) {
396            return $driver->getCourses();
397        }
398        throw new ILSException('No suitable backend driver found');
399    }
400
401    /**
402     * Find Reserves
403     *
404     * Obtain information on course reserves.
405     *
406     * @param string $course ID from getCourses (empty string to match all)
407     * @param string $inst   ID from getInstructors (empty string to match all)
408     * @param string $dept   ID from getDepartments (empty string to match all)
409     *
410     * @return mixed An array of associative arrays representing reserve items
411     */
412    public function findReserves($course, $inst, $dept)
413    {
414        if ($driver = $this->getDriver($this->defaultDriver)) {
415            return $this->addIdPrefixes(
416                $driver->findReserves($course, $inst, $dept),
417                $this->defaultDriver,
418                ['BIB_ID']
419            );
420        }
421        throw new ILSException('No suitable backend driver found');
422    }
423
424    /**
425     * Get Patron Profile
426     *
427     * This is responsible for retrieving the profile for a specific patron.
428     *
429     * @param array $patron The patron array
430     *
431     * @return mixed Array of the patron's profile data
432     */
433    public function getMyProfile($patron)
434    {
435        $source = $this->getSource($patron['cat_username']);
436        if ($driver = $this->getDriver($source)) {
437            return $this->addIdPrefixes(
438                $driver->getMyProfile($this->stripIdPrefixes($patron, $source)),
439                $source
440            );
441        }
442        // Return an empty array if driver is not available; cat_username can point
443        // to an ILS that's not currently configured.
444        return [];
445    }
446
447    /**
448     * Get Patron Holds
449     *
450     * This is responsible for retrieving all holds by a specific patron.
451     *
452     * @param array $patron The patron array from patronLogin
453     *
454     * @return mixed      Array of the patron's holds
455     */
456    public function getMyHolds($patron)
457    {
458        $source = $this->getSource($patron['cat_username']);
459        $holds = $this->callMethodIfSupported(
460            $source,
461            __FUNCTION__,
462            func_get_args(),
463            true,
464            false
465        );
466        return $this->addIdPrefixes($holds, $source, self::HOLD_ID_FIELDS);
467    }
468
469    /**
470     * Get Patron Call Slips
471     *
472     * This is responsible for retrieving all call slips by a specific patron.
473     *
474     * @param array $patron The patron array from patronLogin
475     *
476     * @return mixed      Array of the patron's holds
477     */
478    public function getMyStorageRetrievalRequests($patron)
479    {
480        $source = $this->getSource($patron['cat_username']);
481        if ($driver = $this->getDriver($source)) {
482            $params = [
483                $this->stripIdPrefixes($patron, $source),
484            ];
485            if (!$this->driverSupportsMethod($driver, __FUNCTION__, $params)) {
486                // Return empty array if not supported by the driver
487                return [];
488            }
489            $requests = $driver->getMyStorageRetrievalRequests(...$params);
490            return $this->addIdPrefixes($requests, $source);
491        }
492        throw new ILSException('No suitable backend driver found');
493    }
494
495    /**
496     * Check whether a hold or recall request is valid
497     *
498     * This is responsible for determining if an item is requestable
499     *
500     * @param string $id     The Bib ID
501     * @param array  $data   An Array of item data
502     * @param array  $patron An array of patron data
503     *
504     * @return mixed An array of data on the request including
505     * whether or not it is valid and a status message. Alternatively a boolean
506     * true if request is valid, false if not.
507     */
508    public function checkRequestIsValid($id, $data, $patron)
509    {
510        if (!isset($patron['cat_username'])) {
511            return false;
512        }
513        $source = $this->getSource($patron['cat_username']);
514        if ($driver = $this->getDriver($source)) {
515            if (!$this->driverSupportsSource($source, $id)) {
516                return false;
517            }
518            return $driver->checkRequestIsValid(
519                $this->stripIdPrefixes($id, $source),
520                $this->stripIdPrefixes($data, $source),
521                $this->stripIdPrefixes($patron, $source)
522            );
523        }
524        return false;
525    }
526
527    /**
528     * Check whether a storage retrieval request is valid
529     *
530     * This is responsible for determining if an item is requestable
531     *
532     * @param string $id     The Bib ID
533     * @param array  $data   An Array of item data
534     * @param array  $patron An array of patron data
535     *
536     * @return mixed An array of data on the request including
537     * whether or not it is valid and a status message. Alternatively a boolean
538     * true if request is valid, false if not.
539     */
540    public function checkStorageRetrievalRequestIsValid($id, $data, $patron)
541    {
542        $source = $this->getSource($patron['cat_username']);
543        if ($driver = $this->getDriver($source)) {
544            if (
545                !$this->driverSupportsSource($source, $id)
546                || !is_callable([$driver, 'checkStorageRetrievalRequestIsValid'])
547            ) {
548                return false;
549            }
550            return $driver->checkStorageRetrievalRequestIsValid(
551                $this->stripIdPrefixes($id, $source),
552                $this->stripIdPrefixes($data, $source),
553                $this->stripIdPrefixes($patron, $source)
554            );
555        }
556        return false;
557    }
558
559    /**
560     * Get Pick Up Locations
561     *
562     * This is responsible get a list of valid library locations for holds / recall
563     * retrieval
564     *
565     * @param array $patron      Patron information returned by the patronLogin
566     * method.
567     * @param array $holdDetails Optional array, only passed in when getting a list
568     * in the context of placing or editing a hold. When placing a hold, it contains
569     * most of the same values passed to placeHold, minus the patron data. When
570     * editing a hold it contains all the hold information returned by getMyHolds.
571     * May be used to limit the pickup options or may be ignored. The driver must
572     * not add new options to the return array based on this data or other areas of
573     * VuFind may behave incorrectly.
574     *
575     * @return array        An array of associative arrays with locationID and
576     * locationDisplay keys
577     */
578    public function getPickUpLocations($patron = false, $holdDetails = null)
579    {
580        $source = $this->getSource(
581            $patron['cat_username'] ?? $holdDetails['id'] ?? $holdDetails['item_id']
582            ?? ''
583        );
584        if ($driver = $this->getDriver($source)) {
585            if ($id = ($holdDetails['id'] ?? $holdDetails['item_id'] ?? '')) {
586                if (!$this->driverSupportsSource($source, $id)) {
587                    // Return empty array since the sources don't match
588                    return [];
589                }
590            }
591            $locations = $driver->getPickUpLocations(
592                $this->stripIdPrefixes($patron, $source),
593                $this->stripIdPrefixes(
594                    $holdDetails,
595                    $source,
596                    self::HOLD_ID_FIELDS
597                )
598            );
599            return $this->addIdPrefixes($locations, $source);
600        }
601        throw new ILSException('No suitable backend driver found');
602    }
603
604    /**
605     * Get Default Pick Up Location
606     *
607     * Returns the default pick up location
608     *
609     * @param array $patron      Patron information returned by the patronLogin
610     * method.
611     * @param array $holdDetails Optional array, only passed in when getting a list
612     * in the context of placing a hold; contains most of the same values passed to
613     * placeHold, minus the patron data. May be used to limit the pickup options
614     * or may be ignored.
615     *
616     * @return string A location ID
617     */
618    public function getDefaultPickUpLocation($patron = false, $holdDetails = null)
619    {
620        $source = $this->getSource($patron['cat_username']);
621        if ($driver = $this->getDriver($source)) {
622            if ($holdDetails) {
623                if (!$this->driverSupportsSource($source, $holdDetails['id'])) {
624                    // Return false since the sources don't match
625                    return false;
626                }
627            }
628            $locations = $driver->getDefaultPickUpLocation(
629                $this->stripIdPrefixes($patron, $source),
630                $this->stripIdPrefixes($holdDetails, $source)
631            );
632            return $this->addIdPrefixes($locations, $source);
633        }
634        throw new ILSException('No suitable backend driver found');
635    }
636
637    /**
638     * Get request groups
639     *
640     * @param int   $id          BIB ID
641     * @param array $patron      Patron information returned by the patronLogin
642     * method.
643     * @param array $holdDetails Optional array, only passed in when getting a list
644     * in the context of placing a hold; contains most of the same values passed to
645     * placeHold, minus the patron data. May be used to limit the request group
646     * options or may be ignored.
647     *
648     * @return array  An array of associative arrays with requestGroupId and
649     * name keys
650     */
651    public function getRequestGroups($id, $patron, $holdDetails = null)
652    {
653        // Get source from patron as that will work also with the Demo driver:
654        $source = $this->getSource($patron['cat_username']);
655        if ($driver = $this->getDriver($source)) {
656            $params = [
657                $this->stripIdPrefixes($id, $source),
658                $this->stripIdPrefixes($patron, $source),
659                $this->stripIdPrefixes($holdDetails, $source),
660            ];
661            if (
662                !$this->driverSupportsSource($source, $id)
663                || !$this->driverSupportsMethod($driver, __FUNCTION__, $params)
664            ) {
665                // Return empty array since the sources don't match or the method
666                // isn't supported by the driver
667                return [];
668            }
669            $groups = $driver->getRequestGroups(...$params);
670            return $groups;
671        }
672        throw new ILSException('No suitable backend driver found');
673    }
674
675    /**
676     * Get Default Request Group
677     *
678     * Returns the default request group
679     *
680     * @param array $patron      Patron information returned by the patronLogin
681     * method.
682     * @param array $holdDetails Optional array, only passed in when getting a list
683     * in the context of placing a hold; contains most of the same values passed to
684     * placeHold, minus the patron data. May be used to limit the request group
685     * options or may be ignored.
686     *
687     * @return string A location ID
688     */
689    public function getDefaultRequestGroup($patron, $holdDetails = null)
690    {
691        $source = $this->getSource($patron['cat_username']);
692        if ($driver = $this->getDriver($source)) {
693            $params = [
694                $this->stripIdPrefixes($patron, $source),
695                $this->stripIdPrefixes($holdDetails, $source),
696            ];
697            if (!empty($holdDetails)) {
698                if (
699                    !$this->driverSupportsSource($source, $holdDetails['id'])
700                    || !$this->driverSupportsMethod($driver, __FUNCTION__, $params)
701                ) {
702                    // Return false since the sources don't match or the method
703                    // isn't supported by the driver
704                    return false;
705                }
706            }
707            $locations = $driver->getDefaultRequestGroup(...$params);
708            return $this->addIdPrefixes($locations, $source);
709        }
710        throw new ILSException('No suitable backend driver found');
711    }
712
713    /**
714     * Place Hold
715     *
716     * Attempts to place a hold or recall on a particular item and returns
717     * an array with result details
718     *
719     * @param array $holdDetails An array of item and patron data
720     *
721     * @return mixed An array of data on the request including
722     * whether or not it was successful and a system message (if available)
723     */
724    public function placeHold($holdDetails)
725    {
726        $source = $this->getSource($holdDetails['patron']['cat_username']);
727        if ($driver = $this->getDriver($source)) {
728            if (!$this->driverSupportsSource($source, $holdDetails['id'])) {
729                return [
730                    'success' => false,
731                    'sysMessage' => 'ILSMessages::hold_wrong_user_institution',
732                ];
733            }
734            $holdDetails = $this->stripIdPrefixes($holdDetails, $source);
735            return $driver->placeHold($holdDetails);
736        }
737        throw new ILSException('No suitable backend driver found');
738    }
739
740    /**
741     * Get Cancel Hold Details
742     *
743     * In order to cancel a hold, the ILS requires some information on the hold.
744     * This function returns the required information, which is then submitted
745     * as form data in Hold.php. This value is then extracted by the CancelHolds
746     * function.
747     *
748     * @param array $hold   A single hold array from getMyHolds
749     * @param array $patron Patron information from patronLogin
750     *
751     * @return string Data for use in a form field
752     */
753    public function getCancelHoldDetails($hold, $patron = [])
754    {
755        $source = $this->getSource(
756            $patron['cat_username'] ?? $hold['id'] ?? $hold['item_id'] ?? ''
757        );
758        $params = [
759            $this->stripIdPrefixes(
760                $hold,
761                $source,
762                self::HOLD_ID_FIELDS
763            ),
764            $this->stripIdPrefixes($patron, $source),
765        ];
766        return $this->callMethodIfSupported($source, __FUNCTION__, $params, false);
767    }
768
769    /**
770     * Place Storage Retrieval Request
771     *
772     * Attempts to place a storage retrieval request on a particular item and returns
773     * an array with result details
774     *
775     * @param array $details An array of item and patron data
776     *
777     * @return mixed An array of data on the request including
778     * whether or not it was successful and a system message (if available)
779     */
780    public function placeStorageRetrievalRequest($details)
781    {
782        $source = $this->getSource($details['patron']['cat_username']);
783        $driver = $this->getDriver($source);
784        if (
785            $driver
786            && is_callable([$driver, 'placeStorageRetrievalRequest'])
787        ) {
788            if (!$this->driverSupportsSource($source, $details['id'])) {
789                return [
790                    'success' => false,
791                    'sysMessage' => 'ILSMessages::storage_wrong_user_institution',
792                ];
793            }
794            return $driver->placeStorageRetrievalRequest(
795                $this->stripIdPrefixes($details, $source)
796            );
797        }
798        throw new ILSException('No suitable backend driver found');
799    }
800
801    /**
802     * Check whether an ILL request is valid
803     *
804     * This is responsible for determining if an item is requestable
805     *
806     * @param string $id     The Bib ID
807     * @param array  $data   An Array of item data
808     * @param array  $patron An array of patron data
809     *
810     * @return mixed An array of data on the request including
811     * whether or not it is valid and a status message. Alternatively a boolean
812     * true if request is valid, false if not.
813     */
814    public function checkILLRequestIsValid($id, $data, $patron)
815    {
816        $source = $this->getSource($id);
817        // Patron is not stripped so that the correct library can be determined
818        $params = [
819            $this->stripIdPrefixes($id, $source),
820            $this->stripIdPrefixes($data, $source),
821            $patron,
822        ];
823        return $this->callMethodIfSupported(
824            $source,
825            __FUNCTION__,
826            $params,
827            false,
828            false
829        );
830    }
831
832    /**
833     * Get ILL Pickup Libraries
834     *
835     * This is responsible for getting information on the possible pickup libraries
836     *
837     * @param string $id     Record ID
838     * @param array  $patron Patron
839     *
840     * @return bool|array False if request not allowed, or an array of associative
841     * arrays with libraries.
842     */
843    public function getILLPickupLibraries($id, $patron)
844    {
845        $source = $this->getSource($id);
846        // Patron is not stripped so that the correct library can be determined
847        $params = [
848            $this->stripIdPrefixes($id, $source, ['id']),
849            $patron,
850        ];
851        return $this->callMethodIfSupported(
852            $source,
853            __FUNCTION__,
854            $params,
855            false,
856            false
857        );
858    }
859
860    /**
861     * Get ILL Pickup Locations
862     *
863     * This is responsible for getting a list of possible pickup locations for a
864     * library
865     *
866     * @param string $id        Record ID
867     * @param string $pickupLib Pickup library ID
868     * @param array  $patron    Patron
869     *
870     * @return bool|array False if request not allowed, or an array of
871     * locations.
872     */
873    public function getILLPickupLocations($id, $pickupLib, $patron)
874    {
875        $source = $this->getSource($id);
876        // Patron is not stripped so that the correct library can be determined
877        $params = [
878            $this->stripIdPrefixes($id, $source, ['id']),
879            $pickupLib,
880            $patron,
881        ];
882        return $this->callMethodIfSupported(
883            $source,
884            __FUNCTION__,
885            $params,
886            false,
887            false
888        );
889    }
890
891    /**
892     * Place ILL Request
893     *
894     * Attempts to place an ILL request on a particular item and returns
895     * an array with result details (or throws an exception on failure of support
896     * classes)
897     *
898     * @param array $details An array of item and patron data
899     *
900     * @return mixed An array of data on the request including
901     * whether or not it was successful and a system message (if available)
902     */
903    public function placeILLRequest($details)
904    {
905        $source = $this->getSource($details['id']);
906        // Patron is not stripped so that the correct library can be determined
907        $params = [$this->stripIdPrefixes($details, $source, ['id'], ['patron'])];
908        return $this->callMethodIfSupported(
909            $source,
910            __FUNCTION__,
911            $params,
912            false,
913            false
914        );
915    }
916
917    /**
918     * Get Patron ILL Requests
919     *
920     * This is responsible for retrieving all ILL Requests by a specific patron.
921     *
922     * @param array $patron The patron array from patronLogin
923     *
924     * @return mixed      Array of the patron's ILL requests
925     */
926    public function getMyILLRequests($patron)
927    {
928        $source = $this->getSource($patron['cat_username']);
929        if ($driver = $this->getDriver($source)) {
930            $params = [
931                $this->stripIdPrefixes($patron, $source),
932            ];
933            if (!$this->driverSupportsMethod($driver, __FUNCTION__, $params)) {
934                // Return empty array if not supported by the driver
935                return [];
936            }
937            $requests = $driver->getMyILLRequests(...$params);
938            return $this->addIdPrefixes(
939                $requests,
940                $source,
941                ['id', 'item_id', 'cat_username']
942            );
943        }
944        throw new ILSException('No suitable backend driver found');
945    }
946
947    /**
948     * Check whether the patron is blocked from placing requests (holds/ILL/SRR).
949     *
950     * @param array $patron Patron data from patronLogin().
951     *
952     * @return mixed A boolean false if no blocks are in place and an array
953     * of block reasons if blocks are in place
954     */
955    public function getRequestBlocks($patron)
956    {
957        $source = $this->getSource($patron['cat_username']);
958        if ($driver = $this->getDriver($source)) {
959            $params = [
960                $this->stripIdPrefixes($patron, $source),
961            ];
962            if (!$this->driverSupportsMethod($driver, __FUNCTION__, $params)) {
963                return false;
964            }
965            return $driver->getRequestBlocks(...$params);
966        }
967        throw new ILSException('No suitable backend driver found');
968    }
969
970    /**
971     * Check whether the patron has any blocks on their account.
972     *
973     * @param array $patron Patron data from patronLogin().
974     *
975     * @return mixed A boolean false if no blocks are in place and an array
976     * of block reasons if blocks are in place
977     */
978    public function getAccountBlocks($patron)
979    {
980        $source = $this->getSource($patron['cat_username']);
981        if ($driver = $this->getDriver($source)) {
982            $params = [
983                $this->stripIdPrefixes($patron, $source),
984            ];
985            if (!$this->driverSupportsMethod($driver, __FUNCTION__, $params)) {
986                return false;
987            }
988            return $driver->getAccountBlocks(...$params);
989        }
990        throw new ILSException('No suitable backend driver found');
991    }
992
993    /**
994     * Function which specifies renew, hold and cancel settings.
995     *
996     * @param string $function The name of the feature to be checked
997     * @param array  $params   Optional feature-specific parameters (array)
998     *
999     * @return array An array with key-value pairs.
1000     */
1001    public function getConfig($function, $params = [])
1002    {
1003        $source = null;
1004        if (!empty($params)) {
1005            $source = $this->getSourceForMethod($function, $params);
1006        }
1007        if (!$source) {
1008            try {
1009                $patron = $this->ilsAuth->getStoredCatalogCredentials();
1010                if ($patron && isset($patron['cat_username'])) {
1011                    $source = $this->getSource($patron['cat_username']);
1012                }
1013            } catch (ILSException $e) {
1014                return [];
1015            }
1016        }
1017
1018        $driver = $this->getDriver($source);
1019
1020        // If we have resolved the needed driver, call getConfig and return.
1021        if ($driver && $this->driverSupportsMethod($driver, 'getConfig', $params)) {
1022            return $driver->getConfig(
1023                $function,
1024                $this->stripIdPrefixes($params, $source)
1025            );
1026        }
1027
1028        // If driver not available, return an empty array
1029        return [];
1030    }
1031
1032    /**
1033     * Helper method to determine whether or not a certain method can be
1034     * called on this driver. Required method for any smart drivers.
1035     *
1036     * @param string $method The name of the called method.
1037     * @param array  $params Array of passed parameters.
1038     *
1039     * @return bool True if the method can be called with the given parameters,
1040     * false otherwise.
1041     */
1042    public function supportsMethod(string $method, array $params)
1043    {
1044        if ($method == 'getLoginDrivers' || $method == 'getDefaultLoginDriver') {
1045            return true;
1046        }
1047
1048        $source = $this->getSourceForMethod($method, $params);
1049        if (!$source && $this->defaultDriver) {
1050            $source = $this->defaultDriver;
1051        }
1052        if (!$source) {
1053            // If we can't determine the source, assume we are capable of handling
1054            // the request unless the method is one that doesn't have parameters that
1055            // allow the correct source to be determined.
1056            return !in_array($method, $this->methodsWithNoSourceSpecificParameters);
1057        }
1058
1059        $driver = $this->getDriver($source);
1060        return $driver && $this->driverSupportsMethod($driver, $method, $params);
1061    }
1062
1063    /**
1064     * Default method -- pass along calls to the driver if a source can be determined
1065     * and a driver is available. Throws ILSException otherwise.
1066     *
1067     * @param string $methodName The name of the called method
1068     * @param array  $params     Array of passed parameters
1069     *
1070     * @throws ILSException
1071     * @return mixed             Varies by method
1072     */
1073    public function __call($methodName, $params)
1074    {
1075        return $this->callMethodIfSupported(null, $methodName, $params);
1076    }
1077
1078    /**
1079     * Extract local ID from the given prefixed ID
1080     *
1081     * @param string $id The id to be split
1082     *
1083     * @return string  Local ID
1084     */
1085    protected function getLocalId($id)
1086    {
1087        $pos = strpos($id, '.');
1088        if ($pos > 0) {
1089            return substr($id, $pos + 1);
1090        }
1091        $this->debug("Could not find local id in '$id'");
1092        return $id;
1093    }
1094
1095    /**
1096     * Extract source from the given ID
1097     *
1098     * @param string $id The id to be split
1099     *
1100     * @return string Source
1101     */
1102    protected function getSource($id)
1103    {
1104        $pos = strpos($id, '.');
1105        if ($pos > 0) {
1106            return substr($id, 0, $pos);
1107        }
1108
1109        return '';
1110    }
1111
1112    /**
1113     * Get source for a method and parameters
1114     *
1115     * @param string $method Method
1116     * @param array  $params Parameters
1117     *
1118     * @return string
1119     */
1120    protected function getSourceForMethod(string $method, array $params): string
1121    {
1122        $source = '';
1123        $checkFields = $this->sourceCheckFields[$method] ?? null;
1124        if ($checkFields) {
1125            $source = $this->getSourceFromParams($params, (array)$checkFields);
1126        } else {
1127            $source = $this->getSourceFromParams($params);
1128        }
1129        return $source;
1130    }
1131
1132    /**
1133     * Get source from method parameters
1134     *
1135     * @param array $params      Parameters of a driver method call
1136     * @param array $allowedKeys Keys to use for source identification
1137     *
1138     * @return string Source id or empty string if not found
1139     */
1140    protected function getSourceFromParams(
1141        $params,
1142        $allowedKeys = [0, 'id', 'cat_username']
1143    ) {
1144        if (!is_array($params)) {
1145            if (is_string($params)) {
1146                $source = $this->getSource($params);
1147                if ($source && isset($this->drivers[$source])) {
1148                    return $source;
1149                }
1150            }
1151            return '';
1152        }
1153        foreach ($params as $key => $value) {
1154            $source = false;
1155            if (is_array($value) && (is_int($key) || $key === 'patron')) {
1156                $source = $this->getSourceFromParams($value, $allowedKeys);
1157            } elseif (in_array($key, $allowedKeys)) {
1158                $source = $this->getSource($value);
1159            }
1160            if ($source && isset($this->drivers[$source])) {
1161                return $source;
1162            }
1163        }
1164        return '';
1165    }
1166
1167    /**
1168     * Find the correct driver for the correct configuration file for the
1169     * given source and cache an initialized copy of it.
1170     *
1171     * @param string $source The source name of the driver to get.
1172     *
1173     * @return mixed On success a driver object, otherwise null.
1174     */
1175    protected function getDriver($source)
1176    {
1177        if (!$source) {
1178            // Check for default driver
1179            if ($this->defaultDriver) {
1180                $this->debug('Using default driver ' . $this->defaultDriver);
1181                $source = $this->defaultDriver;
1182            }
1183        }
1184        return parent::getDriver($source);
1185    }
1186
1187    /**
1188     * Change local ID's to global ID's in the given array
1189     *
1190     * @param mixed  $data         The data to be modified, normally
1191     * array or array of arrays
1192     * @param string $source       Source code
1193     * @param array  $modifyFields Fields to be modified in the array
1194     *
1195     * @return mixed     Modified array or empty/null if that input was
1196     *                   empty/null
1197     */
1198    protected function addIdPrefixes(
1199        $data,
1200        $source,
1201        $modifyFields = ['id', 'cat_username']
1202    ) {
1203        if (empty($source) || empty($data) || !is_array($data)) {
1204            return $data;
1205        }
1206
1207        foreach ($data as $key => $value) {
1208            if (null === $value) {
1209                continue;
1210            }
1211            if (is_array($value)) {
1212                $data[$key] = $this->addIdPrefixes(
1213                    $value,
1214                    $source,
1215                    $modifyFields
1216                );
1217            } else {
1218                if (
1219                    !ctype_digit((string)$key)
1220                    && $value !== ''
1221                    && in_array($key, $modifyFields)
1222                ) {
1223                    $data[$key] = "$source.$value";
1224                }
1225            }
1226        }
1227        return $data;
1228    }
1229
1230    /**
1231     * Change global ID's to local ID's in the given array
1232     *
1233     * @param mixed  $data         The data to be modified, normally
1234     * array or array of arrays
1235     * @param string $source       Source code
1236     * @param array  $modifyFields Fields to be modified in the array
1237     * @param array  $ignoreFields Fields to be ignored during recursive processing
1238     *
1239     * @return mixed     Modified array or empty/null if that input was
1240     *                   empty/null
1241     */
1242    protected function stripIdPrefixes(
1243        $data,
1244        $source,
1245        $modifyFields = ['id', 'cat_username'],
1246        $ignoreFields = []
1247    ) {
1248        if (!isset($data) || empty($data)) {
1249            return $data;
1250        }
1251        $array = is_array($data) ? $data : [$data];
1252
1253        foreach ($array as $key => $value) {
1254            if (null === $value) {
1255                continue;
1256            }
1257            if (is_array($value)) {
1258                if (in_array($key, $ignoreFields)) {
1259                    continue;
1260                }
1261                $array[$key] = $this->stripIdPrefixes(
1262                    $value,
1263                    $source,
1264                    $modifyFields
1265                );
1266            } else {
1267                $prefixLen = strlen($source) + 1;
1268                if (
1269                    (!is_array($data)
1270                    || (!ctype_digit((string)$key) && in_array($key, $modifyFields)))
1271                    && strncmp("$source.", $value, $prefixLen) == 0
1272                ) {
1273                    $array[$key] = substr($value, $prefixLen);
1274                }
1275            }
1276        }
1277        return is_array($data) ? $array : $array[0];
1278    }
1279
1280    /**
1281     * Check if the given ILS driver supports the source of a record
1282     *
1283     * @param string $driverSource Driver's source identifier
1284     * @param string $id           Prefixed identifier to compare with
1285     *
1286     * @return bool
1287     */
1288    protected function driverSupportsSource(string $driverSource, string $id): bool
1289    {
1290        // Same source is always ok:
1291        if ($this->getSource($id) === $driverSource) {
1292            return true;
1293        }
1294        // Demo driver supports any record source:
1295        $driver = $this->getDriver($driverSource);
1296        return $driver instanceof \VuFind\ILS\Driver\Demo;
1297    }
1298
1299    /**
1300     * Check that the requested method is supported and call it.
1301     *
1302     * @param string $source        Source ID or null to determine from parameters
1303     * @param string $method        Method name
1304     * @param array  $params        Method parameters
1305     * @param bool   $stripPrefixes Whether to strip ID prefixes from all input
1306     * parameters
1307     * @param bool   $addPrefixes   Whether to add ID prefixes to the call result
1308     *
1309     * @return mixed
1310     * @throws ILSException
1311     */
1312    protected function callMethodIfSupported(
1313        ?string $source,
1314        string $method,
1315        array $params,
1316        bool $stripPrefixes = true,
1317        bool $addPrefixes = true
1318    ) {
1319        if (null === $source) {
1320            $source = $this->getSourceForMethod($method, $params);
1321        }
1322        $driver = $this->getDriver($source);
1323        if ($driver) {
1324            if ($stripPrefixes) {
1325                foreach ($params as &$param) {
1326                    $param = $this->stripIdPrefixes($param, $source);
1327                }
1328                unset($param);
1329            }
1330            if ($this->driverSupportsMethod($driver, $method, $params)) {
1331                $result = call_user_func_array([$driver, $method], $params);
1332                if ($addPrefixes) {
1333                    $result = $this->addIdPrefixes($result, $source);
1334                }
1335                return $result;
1336            }
1337        }
1338        throw new ILSException('No suitable backend driver found');
1339    }
1340}