Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
20.95% covered (danger)
20.95%
207 / 988
21.43% covered (danger)
21.43%
9 / 42
CRAP
0.00% covered (danger)
0.00%
0 / 1
Alma
20.95% covered (danger)
20.95%
207 / 988
21.43% covered (danger)
21.43%
9 / 42
27280.68
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 makeRequest
0.00% covered (danger)
0.00%
0 / 65
0.00% covered (danger)
0.00%
0 / 1
272
 getItemAvailabilityAndStatus
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
10
 getItemStatusFromLocationTypeMap
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 getHolding
100.00% covered (success)
100.00%
59 / 59
100.00% covered (success)
100.00%
1 / 1
14
 checkRequestIsValid
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
56
 getRequestBlocks
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAccountBlocks
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
132
 getFulfillmentUnitByLocation
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 createAlmaUser
0.00% covered (danger)
0.00%
0 / 71
0.00% covered (danger)
0.00%
0 / 1
72
 patronLogin
0.00% covered (danger)
0.00%
0 / 91
0.00% covered (danger)
0.00%
0 / 1
156
 getMyProfile
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
306
 getMyFines
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 getMyHolds
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
132
 cancelHolds
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
20
 updateHolds
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
20
 getMyStorageRetrievalRequests
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
30
 getMyILLRequests
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
30
 getMyTransactions
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 1
156
 getRenewDetails
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 renewMyItems
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
12
 getStatus
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getStatuses
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPurchaseHistory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getConfig
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
42
 placeHold
0.00% covered (danger)
0.00%
0 / 61
0.00% covered (danger)
0.00%
0 / 1
42
 getPickupLocations
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getCourses
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 findReserves
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 parseDate
48.84% covered (danger)
48.84%
21 / 43
0.00% covered (danger)
0.00%
0 / 1
40.25
 supportsMethod
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getInventoryTypes
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 getStatusesForInventoryTypes
51.95% covered (warning)
51.95%
40 / 77
0.00% covered (danger)
0.00%
0 / 1
53.95
 getPreferredEmail
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
42
 getTranslatableString
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 getTranslatableStatusString
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 getItemLocation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getItemLocationType
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
6.00
 getLocationType
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getLocations
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 getFunds
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3/**
4 * Alma ILS Driver
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2017.
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  ILS_Drivers
25 * @author   Demian Katz <demian.katz@villanova.edu>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
28 */
29
30namespace VuFind\ILS\Driver;
31
32use Laminas\Http\Headers;
33use SimpleXMLElement;
34use VuFind\Exception\ILS as ILSException;
35use VuFind\I18n\TranslatableString;
36use VuFind\I18n\Translator\TranslatorAwareInterface;
37use VuFind\I18n\Translator\TranslatorAwareTrait;
38use VuFind\ILS\Logic\AvailabilityStatusInterface;
39use VuFind\Marc\MarcReader;
40
41use function count;
42use function floatval;
43use function in_array;
44use function is_callable;
45
46/**
47 * Alma ILS Driver
48 *
49 * @category VuFind
50 * @package  ILS_Drivers
51 * @author   Demian Katz <demian.katz@villanova.edu>
52 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
53 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
54 */
55class Alma extends AbstractBase implements
56    \VuFindHttp\HttpServiceAwareInterface,
57    \Laminas\Log\LoggerAwareInterface,
58    TranslatorAwareInterface
59{
60    use \VuFindHttp\HttpServiceAwareTrait;
61    use \VuFind\Log\LoggerAwareTrait;
62    use \VuFind\Cache\CacheTrait;
63    use TranslatorAwareTrait;
64
65    /**
66     * Alma API base URL.
67     *
68     * @var string
69     */
70    protected $baseUrl;
71
72    /**
73     * Alma API key.
74     *
75     * @var string
76     */
77    protected $apiKey;
78
79    /**
80     * Date converter
81     *
82     * @var \VuFind\Date\Converter
83     */
84    protected $dateConverter;
85
86    /**
87     * Mappings from location type to item status. Overrides any other item status.
88     *
89     * @var array
90     */
91    protected $locationTypeToItemStatus = [];
92
93    /**
94     * Constructor
95     *
96     * @param \VuFind\Date\Converter $dateConverter Date converter object
97     */
98    public function __construct(\VuFind\Date\Converter $dateConverter)
99    {
100        $this->dateConverter = $dateConverter;
101    }
102
103    /**
104     * Initialize the driver.
105     *
106     * Validate configuration and perform all resource-intensive tasks needed to
107     * make the driver active.
108     *
109     * @throws ILSException
110     * @return void
111     */
112    public function init()
113    {
114        if (empty($this->config)) {
115            throw new ILSException('Configuration needs to be set.');
116        }
117        $this->baseUrl = $this->config['Catalog']['apiBaseUrl'];
118        $this->apiKey = $this->config['Catalog']['apiKey'];
119
120        if (!empty($this->config['Holdings']['locationTypeItemStatus'])) {
121            $this->locationTypeToItemStatus
122                = $this->config['Holdings']['locationTypeItemStatus'];
123        }
124    }
125
126    /**
127     * Make an HTTP request against Alma
128     *
129     * @param string        $path          Path to retrieve from API (excluding base
130     *                                     URL/API key)
131     * @param array         $paramsGet     Additional GET params
132     * @param array         $paramsPost    Additional POST params
133     * @param string        $method        GET or POST. Default is GET.
134     * @param string        $rawBody       Request body.
135     * @param Headers|array $headers       Add headers to the call.
136     * @param array         $allowedErrors HTTP status codes that are not treated as
137     *                                     API errors.
138     * @param bool          $returnStatus  Whether to return HTTP status in addition
139     *                                     to the response.
140     *
141     * @throws ILSException
142     * @return null|SimpleXMLElement|array
143     */
144    protected function makeRequest(
145        $path,
146        $paramsGet = [],
147        $paramsPost = [],
148        $method = 'GET',
149        $rawBody = null,
150        $headers = null,
151        $allowedErrors = [],
152        $returnStatus = false
153    ) {
154        // Set some variables
155        $url = null;
156        $result = null;
157        $statusCode = null;
158        $returnValue = null;
159        $startTime = microtime(true);
160
161        try {
162            // Set API key if it is not already available in the GET params
163            if (!isset($paramsGet['apikey'])) {
164                $paramsGet['apikey'] = $this->apiKey;
165            }
166
167            // Create the API URL
168            $url = !str_contains($path, '://') ? $this->baseUrl . $path : $path;
169
170            // Create client with API URL
171            $client = $this->httpService->createClient($url);
172
173            // Set method
174            $client->setMethod($method);
175
176            // Set timeout
177            $timeout = $this->config['Catalog']['http_timeout'] ?? 30;
178            $client->setOptions(['timeout' => $timeout]);
179
180            // Set other GET parameters (apikey and other URL parameters are used
181            // also with e.g. POST requests)
182            $client->setParameterGet($paramsGet);
183            // Set POST parameters
184            if ($method == 'POST') {
185                $client->setParameterPost($paramsPost);
186            }
187
188            // Set body if applicable
189            if (isset($rawBody)) {
190                $client->setRawBody($rawBody);
191            }
192
193            // Set headers if applicable
194            if (isset($headers)) {
195                $client->setHeaders($headers);
196            }
197
198            // Execute HTTP call
199            $result = $client->send();
200        } catch (\Exception $e) {
201            $this->logError("$method request '$url' failed: " . $e->getMessage());
202            $this->throwAsIlsException($e);
203        }
204
205        // Get the HTTP status code and response
206        $statusCode = $result->getStatusCode();
207        $answer = $statusCode !== 204 ? $result->getBody() : '';
208        $answer = str_replace('xmlns=', 'ns=', $answer);
209
210        $duration = round(microtime(true) - $startTime, 4);
211        $urlParams = $client->getRequest()->getQuery()->toString();
212        $fullUrl = $url . (!str_contains($url, '?') ? '?' : '&') . $urlParams;
213        $this->debug(
214            "[$duration$method request '$fullUrl' results ($statusCode):\n"
215            . $answer
216        );
217
218        // Check for error
219        if ($result->isServerError()) {
220            $this->logError(
221                "$method request '$url' failed, HTTP error code: $statusCode"
222            );
223            throw new ILSException('HTTP error code: ' . $statusCode, $statusCode);
224        }
225
226        try {
227            $xml = simplexml_load_string($answer);
228        } catch (\Exception $e) {
229            $this->logError(
230                "Could not parse response for $method request '$url': "
231                . $e->getMessage() . ". Response was:\n"
232                . $result->getHeaders()->toString()
233                . "\n\n$answer"
234            );
235            $this->throwAsIlsException($e);
236        }
237        if ($result->isSuccess() || in_array($statusCode, $allowedErrors)) {
238            if (!$xml && $result->isServerError()) {
239                $error = 'XML is not valid or HTTP error, URL: ' . $url .
240                    ', HTTP status code: ' . $statusCode;
241                $this->logError($error);
242                throw new ILSException($error, $statusCode);
243            }
244            $returnValue = $xml;
245        } else {
246            $almaErrorMsg = $xml->errorList->error[0]->errorMessage
247                ?? '[could not parse error message]';
248            $errorMsg = "Alma error for $method request '$url' (status code"
249                . " $statusCode): $almaErrorMsg";
250            $this->logError(
251                $errorMsg . '. GET params: ' . $this->varDump($paramsGet)
252                . '. POST params: ' . $this->varDump($paramsPost)
253                . '. Result body: ' . $result->getBody()
254            );
255            throw new ILSException($errorMsg, $statusCode);
256        }
257
258        return $returnStatus ? [$returnValue, $statusCode] : $returnValue;
259    }
260
261    /**
262     * Given an item, return its availability and status.
263     *
264     * @param \SimpleXMLElement $item Item data
265     *
266     * @return array Availability and status
267     */
268    protected function getItemAvailabilityAndStatus(\SimpleXMLElement $item): array
269    {
270        // Check location type to status mappings first since they override
271        // everything else:
272        $available = null;
273        $status = null;
274        if ($this->locationTypeToItemStatus) {
275            [$available, $status] = $this->getItemStatusFromLocationTypeMap(
276                $this->getItemLocationType($item)
277            );
278        }
279
280        // Normal checks for status if no mapping found above:
281        if (null === $status) {
282            $status = (string)$item->item_data->base_status[0]->attributes()['desc'];
283            $duedate = $item->item_data->due_date
284                ? $this->parseDate((string)$item->item_data->due_date) : null;
285            if ($duedate && 'Item not in place' === $status) {
286                $status = 'Checked Out';
287            }
288
289            $processType = (string)($item->item_data->process_type ?? '');
290            if ($processType && 'LOAN' !== $processType) {
291                $status = $this->getTranslatableStatusString(
292                    $item->item_data->process_type
293                );
294            }
295        }
296
297        // Normal check for availability if no mapping found above:
298        if (null === $available) {
299            $available = (string)$item->item_data->base_status === '1'
300                ? AvailabilityStatusInterface::STATUS_AVAILABLE
301                : AvailabilityStatusInterface::STATUS_UNAVAILABLE;
302        }
303
304        return [$available, $status];
305    }
306
307    /**
308     * Given an item, return its availability and status based on location type
309     * mappings.
310     *
311     * @param string $locationType Location type
312     *
313     * @return array Availability and status
314     */
315    protected function getItemStatusFromLocationTypeMap(string $locationType): array
316    {
317        if (null === ($setting = $this->locationTypeToItemStatus[$locationType] ?? null)) {
318            return [null, null];
319        }
320        $parts = explode(':', $setting);
321        $available = null;
322        $status = new TranslatableString($parts[0], $parts[0]);
323        if (isset($parts[1])) {
324            switch ($parts[1]) {
325                case 'unavailable':
326                    $available = AvailabilityStatusInterface::STATUS_UNAVAILABLE;
327                    break;
328                case 'uncertain':
329                    $available = AvailabilityStatusInterface::STATUS_UNCERTAIN;
330                    break;
331                default:
332                    $available = AvailabilityStatusInterface::STATUS_AVAILABLE;
333                    break;
334            }
335        }
336        return [$available, $status];
337    }
338
339    /**
340     * Get Holding
341     *
342     * This is responsible for retrieving the holding information of a certain
343     * record.
344     *
345     * @param string $id      The record id to retrieve the holdings for
346     * @param array  $patron  Patron data
347     * @param array  $options Additional options
348     *
349     * @return array On success an array with the key "total" containing the total
350     * number of items for the given bib id, and the key "holdings" containing an
351     * array of holding information each one with these keys: id, source,
352     * availability, status, location, reserve, callnumber, duedate, returnDate,
353     * number, barcode, item_notes, item_id, holdings_id, addLink, description
354     */
355    public function getHolding($id, $patron = null, array $options = [])
356    {
357        // Prepare result array with default values. If no API result can be received
358        // these will be returned.
359        $results = ['total' => 0, 'holdings' => []];
360
361        // Correct copy count in case of paging
362        $copyCount = $options['offset'] ?? 0;
363
364        // Paging parameters for paginated API call. The "limit" tells the API how
365        // many items the call should return at once (e. g. 10). The "offset" defines
366        // the range (e. g. get items 30 to 40). With these parameters we are able to
367        // use a paginator for paging through many items.
368        $apiPagingParams = '';
369        if ($options['itemLimit'] ?? null) {
370            $apiPagingParams = '&limit=' . urlencode($options['itemLimit'])
371                . '&offset=' . urlencode($options['offset'] ?? 0);
372        }
373
374        // The path for the API call. We call "ALL" available items, but not at once
375        // as a pagination mechanism is used. If paging params are not set for some
376        // reason, the first 10 items are called which is the default API behaviour.
377        $itemsPath = '/bibs/' . rawurlencode($id) . '/holdings/ALL/items'
378            . '?order_by=library,location,enum_a,enum_b&direction=desc'
379            . '&expand=due_date'
380            . $apiPagingParams;
381
382        if ($items = $this->makeRequest($itemsPath)) {
383            // Get the total number of items returned from the API call and set it to
384            // a class variable. It is then used in VuFind\RecordTab\HoldingsILS for
385            // the items paginator.
386            $results['total'] = (int)$items->attributes()->total_record_count;
387
388            foreach ($items->item as $item) {
389                $number = ++$copyCount;
390                $holdingId = (string)$item->holding_data->holding_id;
391                $itemId = (string)$item->item_data->pid;
392                $barcode = (string)$item->item_data->barcode;
393                $itemNotes = !empty($item->item_data->public_note)
394                    ? [(string)$item->item_data->public_note] : null;
395                $duedate = $item->item_data->due_date
396                    ? $this->parseDate((string)$item->item_data->due_date) : null;
397                [$available, $status] = $this->getItemAvailabilityAndStatus($item);
398
399                $description = null;
400                if (!empty($item->item_data->description)) {
401                    $number = (string)$item->item_data->description;
402                    $description = (string)$item->item_data->description;
403                }
404                $callnumber = $item->holding_data->call_number;
405                $results['holdings'][] = [
406                    'id' => $id,
407                    'source' => 'Solr',
408                    'availability' => $available,
409                    'status' => $status,
410                    'location' => $this->getItemLocation($item),
411                    'reserve' => 'N',   // TODO: support reserve status
412                    'callnumber' => (string)($callnumber->desc ?? $callnumber),
413                    'duedate' => $duedate,
414                    'returnDate' => false, // TODO: support recent returns
415                    'number' => $number,
416                    'barcode' => empty($barcode) ? 'n/a' : $barcode,
417                    'item_notes' => $itemNotes ?? null,
418                    'item_id' => $itemId,
419                    'holdings_id' => $holdingId,
420                    'holding_id' => $holdingId, // deprecated, retained for backward compatibility
421                    'holdtype' => 'auto',
422                    'addLink' => $patron ? 'check' : false,
423                    // For Alma title-level hold requests
424                    'description' => $description ?? null,
425                ];
426            }
427        }
428
429        // Fetch also digital and/or electronic inventory if configured
430        $types = $this->getInventoryTypes();
431        if (in_array('d_avail', $types) || in_array('e_avail', $types)) {
432            // No need for physical items
433            $key = array_search('p_avail', $types);
434            if (false !== $key) {
435                unset($types[$key]);
436            }
437            $statuses = $this->getStatusesForInventoryTypes((array)$id, $types);
438            $electronic = [];
439            foreach ($statuses as $record) {
440                foreach ($record as $status) {
441                    $electronic[] = $status;
442                }
443            }
444            $results['electronic_holdings'] = $electronic;
445        }
446
447        return $results;
448    }
449
450    /**
451     * Check if request is valid
452     *
453     * This is responsible for determining if an item is requestable
454     *
455     * @param string $id     The record id
456     * @param array  $data   An array of item data
457     * @param array  $patron An array of patron data
458     *
459     * @return bool True if request is valid, false if not
460     */
461    public function checkRequestIsValid($id, $data, $patron)
462    {
463        $patronId = $patron['id'];
464        $level = $data['level'] ?? 'copy';
465        if ('copy' === $level) {
466            // Call the request-options API for the logged-in user; note that holding_id
467            // is deprecated but retained for backward compatibility.
468            $requestOptionsPath = '/bibs/' . rawurlencode($id)
469                . '/holdings/' . rawurlencode($data['holdings_id'] ?? $data['holding_id'])
470                . '/items/' . rawurlencode($data['item_id']) . '/request-options?user_id='
471                . urlencode($patronId);
472
473            // Make the API request
474            $requestOptions = $this->makeRequest($requestOptionsPath);
475        } elseif ('title' === $level) {
476            $hmac = explode(':', $this->config['Holds']['HMACKeys'] ?? '');
477            if (!in_array('level', $hmac) || !in_array('description', $hmac)) {
478                return false;
479            }
480            // Call the request-options API for the logged-in user
481            $requestOptionsPath = '/bibs/' . rawurlencode($id)
482                . '/request-options?user_id=' . urlencode($patronId);
483
484            // Make the API request
485            $requestOptions = $this->makeRequest($requestOptionsPath);
486        } else {
487            return false;
488        }
489
490        // Check possible request types from the API answer
491        $requestTypes = $requestOptions->xpath(
492            '/request_options/request_option//type'
493        );
494        foreach ($requestTypes as $requestType) {
495            if ('HOLD' === (string)$requestType) {
496                return true;
497            }
498        }
499
500        return false;
501    }
502
503    /**
504     * Check for request blocks.
505     *
506     * @param array $patron The patron array with username and password
507     *
508     * @return array|boolean    An array of block messages or false if there are no
509     *                          blocks
510     * @author Michael Birkner
511     */
512    public function getRequestBlocks($patron)
513    {
514        return $this->getAccountBlocks($patron);
515    }
516
517    /**
518     * Check for account blocks in Alma and cache them.
519     *
520     * @param array $patron The patron array with username and password
521     *
522     * @return array|boolean    An array of block messages or false if there are no
523     *                          blocks
524     * @author Michael Birkner
525     */
526    public function getAccountBlocks($patron)
527    {
528        $patronId = $patron['id'];
529        $cacheId = 'alma|user|' . $patronId . '|blocks';
530        $cachedBlocks = $this->getCachedData($cacheId);
531        if ($cachedBlocks !== null) {
532            return $cachedBlocks;
533        }
534
535        $xml = $this->makeRequest('/users/' . rawurlencode($patronId));
536        if ($xml == null || empty($xml)) {
537            return false;
538        }
539
540        $userBlocks = $xml->user_blocks->user_block;
541        if ($userBlocks == null || empty($userBlocks)) {
542            return false;
543        }
544
545        $blocks = [];
546        foreach ($userBlocks as $block) {
547            $blockStatus = (string)$block->block_status;
548            if ($blockStatus === 'ACTIVE') {
549                $blockNote = (isset($block->block_note))
550                             ? (string)$block->block_note
551                             : null;
552                $blockDesc = (string)$block->block_description->attributes()->desc;
553                $blockDesc = ($blockNote != null)
554                             ? $blockDesc . '. ' . $blockNote
555                             : $blockDesc;
556                $blocks[] = $blockDesc;
557            }
558        }
559
560        if (!empty($blocks)) {
561            $this->putCachedData($cacheId, $blocks);
562            return $blocks;
563        } else {
564            $this->putCachedData($cacheId, false);
565            return false;
566        }
567    }
568
569    /**
570     * Get an Alma fulfillment unit by an Alma location.
571     *
572     * @param string $locationCode     A location code, e. g. "SCI"
573     * @param array  $fulfillmentUnits An array of fulfillment units with all its
574     *                                 locations.
575     *
576     * @return string|NULL              Null if the location was not found or a
577     *                                  string specifying the fulfillment unit of
578     *                                  the location that was found.
579     * @author Michael Birkner
580     */
581    protected function getFulfillmentUnitByLocation($locationCode, $fulfillmentUnits)
582    {
583        foreach ($fulfillmentUnits as $key => $val) {
584            if (array_search($locationCode, $val) !== false) {
585                return $key;
586            }
587        }
588        return null;
589    }
590
591    /**
592     * Create a user in Alma via API call
593     *
594     * @param array $formParams The data from the "create new account" form
595     *
596     * @throws \VuFind\Exception\Auth
597     *
598     * @return NULL|SimpleXMLElement
599     * @author Michael Birkner
600     */
601    public function createAlmaUser($formParams)
602    {
603        // Get config for creating new Alma users from Alma.ini
604        $newUserConfig = $this->config['NewUser'] ?? [];
605
606        // Check if config params are all set
607        $configParams = [
608            'recordType', 'userGroup', 'preferredLanguage',
609            'accountType', 'status', 'emailType', 'idType',
610        ];
611        foreach ($configParams as $configParam) {
612            if (empty(trim($newUserConfig[$configParam] ?? ''))) {
613                $errorMessage = 'Configuration "' . $configParam . '" is not set ' .
614                                'in Alma ini in the [NewUser] section!';
615                $this->logError($errorMessage);
616                throw new \VuFind\Exception\Auth($errorMessage);
617            }
618        }
619
620        // Calculate expiry date based on config in Alma.ini
621        $expiryDate = new \DateTime('now');
622        if (!empty(trim($newUserConfig['expiryDate'] ?? ''))) {
623            try {
624                $expiryDate->add(
625                    new \DateInterval($newUserConfig['expiryDate'])
626                );
627            } catch (\Exception $exception) {
628                $errorMessage = 'Configuration "expiryDate" in Alma ini (see ' .
629                                '[NewUser] section) has the wrong format!';
630                $this->logError($errorMessage);
631                throw new \VuFind\Exception\Auth($errorMessage);
632            }
633        } else {
634            $expiryDate->add(new \DateInterval('P1Y'));
635        }
636
637        // Calculate purge date based on config in Alma.ini
638        $purgeDate = null;
639        if (!empty(trim($newUserConfig['purgeDate'] ?? ''))) {
640            try {
641                $purgeDate = new \DateTime('now');
642                $purgeDate->add(
643                    new \DateInterval($newUserConfig['purgeDate'])
644                );
645            } catch (\Exception $exception) {
646                $errorMessage = 'Configuration "purgeDate" in Alma ini (see ' .
647                                '[NewUser] section) has the wrong format!';
648                $this->logError($errorMessage);
649                throw new \VuFind\Exception\Auth($errorMessage);
650            }
651        }
652
653        // Create user XML for Alma API
654        $xml = simplexml_load_string(
655            '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
656            . "\n\n<user/>"
657        );
658        $xml->addChild('record_type', $newUserConfig['recordType']);
659        $xml->addChild('first_name', $formParams['firstname']);
660        $xml->addChild('last_name', $formParams['lastname']);
661        $xml->addChild('user_group', $newUserConfig['userGroup']);
662        $xml->addChild(
663            'preferred_language',
664            $newUserConfig['preferredLanguage']
665        );
666        $xml->addChild('account_type', $newUserConfig['accountType']);
667        $xml->addChild('status', $newUserConfig['status']);
668        $xml->addChild('expiry_date', $expiryDate->format('Y-m-d') . 'Z');
669        if (null !== $purgeDate) {
670            $xml->addChild('purge_date', $purgeDate->format('Y-m-d') . 'Z');
671        }
672
673        $contactInfo = $xml->addChild('contact_info');
674        $emails = $contactInfo->addChild('emails');
675        $email = $emails->addChild('email');
676        $email->addAttribute('preferred', 'true');
677        $email->addChild('email_address', $formParams['email']);
678        $emailTypes = $email->addChild('email_types');
679        $emailTypes->addChild('email_type', $newUserConfig['emailType']);
680
681        $userIdentifiers = $xml->addChild('user_identifiers');
682        $userIdentifier = $userIdentifiers->addChild('user_identifier');
683        $userIdentifier->addChild('id_type', $newUserConfig['idType']);
684        $userIdentifier->addChild('value', $formParams['username']);
685
686        $userXml = $xml->asXML();
687
688        // Create user in Alma
689        $almaAnswer = $this->makeRequest(
690            '/users',
691            [],
692            [],
693            'POST',
694            $userXml,
695            ['Content-Type' => 'application/xml']
696        );
697
698        // Return the XML from Alma on success. On error, an exception is thrown
699        // in makeRequest
700        return $almaAnswer;
701    }
702
703    /**
704     * Patron Login
705     *
706     * This is responsible for authenticating a patron against the catalog.
707     *
708     * @param string $username The patrons barcode or other username.
709     * @param string $password The patrons password.
710     *
711     * @return string[]|NULL
712     */
713    public function patronLogin($username, $password)
714    {
715        $loginMethod = $this->config['Catalog']['loginMethod'] ?? 'vufind';
716
717        $patron = [];
718        $patronId = $username;
719        if ('email' === $loginMethod) {
720            // Try to find the user in Alma by an identifier
721            [$response, $status] = $this->makeRequest(
722                '/users/' . rawurlencode($username),
723                [
724                    'view' => 'full',
725                ],
726                [],
727                'GET',
728                null,
729                null,
730                [400],
731                true
732            );
733            if (400 != $status) {
734                $patron = [
735                    'id' => (string)$response->primary_id,
736                    'cat_username' => trim($username),
737                    'email' => $this->getPreferredEmail($response),
738                ];
739            } else {
740                // Try to find the user in Alma by unique email address
741                $getParams = [
742                    'q' => 'email~' . $username,
743                ];
744
745                $response = $this->makeRequest(
746                    '/users/',
747                    $getParams
748                );
749
750                foreach (($response->user ?? []) as $user) {
751                    if ((string)$user->status !== 'ACTIVE') {
752                        continue;
753                    }
754                    if ($patron) {
755                        // More than one match, cannot log in by email
756                        $this->debug(
757                            "Email $username matches more than one user, cannot"
758                            . ' login'
759                        );
760                        return null;
761                    }
762                    $patron = [
763                        'id' => (string)$user->primary_id,
764                        'cat_username' => trim($username),
765                        'email' => trim($username),
766                    ];
767                }
768            }
769            if (!$patron) {
770                return null;
771            }
772            // Use primary id in further queries
773            $patronId = $patron['id'];
774        } elseif ('password' === $loginMethod) {
775            // Create parameters for API call
776            $getParams = [
777                'user_id_type' => 'all_unique',
778                'op' => 'auth',
779                'password' => $password,
780            ];
781
782            // Try to authenticate the user with Alma
783            [$response, $status] = $this->makeRequest(
784                '/users/' . rawurlencode($username),
785                $getParams,
786                [],
787                'POST',
788                null,
789                null,
790                [400],
791                true
792            );
793            if (400 === $status) {
794                return null;
795            }
796        } elseif ('vufind' !== $loginMethod) {
797            $this->logError("Invalid login method configured: $loginMethod");
798            throw new ILSException('Invalid login method configured');
799        }
800
801        // Create parameters for API call
802        $getParams = [
803            'user_id_type' => 'all_unique',
804            'view' => 'full',
805            'expand' => 'none',
806        ];
807
808        // Check for patron in Alma
809        [$response, $status] = $this->makeRequest(
810            '/users/' . rawurlencode($patronId),
811            $getParams,
812            [],
813            'GET',
814            null,
815            null,
816            [400],
817            true
818        );
819
820        if ($status != 400 && $response !== null) {
821            // We may already have some information, so just fill the gaps
822            $patron['id'] = (string)$response->primary_id;
823            $patron['cat_username'] = trim($username);
824            $patron['cat_password'] = trim($password);
825            $patron['firstname'] = (string)$response->first_name ?? '';
826            $patron['lastname'] = (string)$response->last_name ?? '';
827            $patron['email'] = $this->getPreferredEmail($response);
828            return $patron;
829        }
830
831        return null;
832    }
833
834    /**
835     * Get Patron Profile
836     *
837     * This is responsible for retrieving the profile for a specific patron.
838     *
839     * @param array $patron The patron array
840     *
841     * @return array Array of the patron's profile data on success.
842     */
843    public function getMyProfile($patron)
844    {
845        $patronId = $patron['id'];
846        $xml = $this->makeRequest('/users/' . rawurlencode($patronId));
847        if (empty($xml)) {
848            return [];
849        }
850        $profile = [
851            'firstname' => (isset($xml->first_name))
852                ? (string)$xml->first_name
853                : null,
854            'lastname' => (isset($xml->last_name))
855                ? (string)$xml->last_name
856                : null,
857            'group' => isset($xml->user_group)
858                ? $this->getTranslatableString($xml->user_group)
859                : null,
860            'group_code' => (isset($xml->user_group))
861                ? (string)$xml->user_group
862                : null,
863        ];
864        $contact = $xml->contact_info;
865        if ($contact) {
866            if ($contact->addresses) {
867                $address = $contact->addresses[0]->address;
868                $profile['address1'] = (isset($address->line1))
869                    ? (string)$address->line1
870                    : null;
871                $profile['address2'] = (isset($address->line2))
872                    ? (string)$address->line2
873                    : null;
874                $profile['address3'] = (isset($address->line3))
875                    ? (string)$address->line3
876                    : null;
877                $profile['zip'] = (isset($address->postal_code))
878                    ? (string)$address->postal_code
879                    : null;
880                $profile['city'] = (isset($address->city))
881                    ? (string)$address->city
882                    : null;
883                $profile['country'] = (isset($address->country))
884                    ? (string)$address->country
885                    : null;
886            }
887            if ($contact->phones) {
888                $profile['phone'] = (isset($contact->phones[0]->phone->phone_number))
889                    ? (string)$contact->phones[0]->phone->phone_number
890                    : null;
891            }
892            $profile['email'] = $this->getPreferredEmail($xml);
893        }
894        if ($xml->birth_date) {
895            // Drop any time zone designator from the date:
896            $profile['birthdate'] = substr((string)$xml->birth_date, 0, 10);
897        }
898
899        // Cache the user group code
900        $cacheId = 'alma|user|' . $patronId . '|group_code';
901        $this->putCachedData($cacheId, $profile['group_code'] ?? null);
902
903        return $profile;
904    }
905
906    /**
907     * Get Patron Fines
908     *
909     * This is responsible for retrieving all fines by a specific patron.
910     *
911     * @param array $patron The patron array from patronLogin
912     *
913     * @return mixed        Array of the patron's fines on success.
914     */
915    public function getMyFines($patron)
916    {
917        $xml = $this->makeRequest(
918            '/users/' . rawurlencode($patron['id']) . '/fees'
919        );
920        $fineList = [];
921        foreach ($xml as $fee) {
922            $created = (string)$fee->creation_time;
923            $checkout = (string)$fee->status_time;
924            $fineList[] = [
925                'title'    => (string)($fee->title ?? ''),
926                'amount'   => round(floatval($fee->original_amount) * 100),
927                'balance'  => round(floatval($fee->balance) * 100),
928                'createdate' => $this->parseDate($created, true),
929                'checkout' => $this->parseDate($checkout, true),
930                'fine'     => $this->getTranslatableString($fee->type),
931            ];
932        }
933        return $fineList;
934    }
935
936    /**
937     * Get Patron Holds
938     *
939     * This is responsible for retrieving all holds by a specific patron.
940     *
941     * @param array $patron The patron array from patronLogin
942     *
943     * @return mixed        Array of the patron's holds on success.
944     *
945     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
946     */
947    public function getMyHolds($patron)
948    {
949        $holdList = [];
950        $offset = 0;
951        $totalCount = 1;
952        $allowCancelingAvailableRequests
953            = $this->config['Holds']['allowCancelingAvailableRequests'] ?? true;
954        while ($offset < $totalCount) {
955            $xml = $this->makeRequest(
956                '/users/' . rawurlencode($patron['id']) . '/requests',
957                ['request_type' => 'HOLD', 'offset' => $offset, 'limit' => 100]
958            );
959            $offset += 100;
960            $totalCount = (int)$xml->attributes()->{'total_record_count'};
961            foreach ($xml as $request) {
962                $lastInterestDate = $request->last_interest_date
963                    ? $this->dateConverter->convertToDisplayDate(
964                        'Y-m-dT',
965                        (string)$request->last_interest_date
966                    ) : null;
967                $available = (string)$request->request_status === 'On Hold Shelf';
968                $lastPickupDate = null;
969                if ($available) {
970                    $lastPickupDate = $request->expiry_date
971                        ? $this->dateConverter->convertToDisplayDate(
972                            'Y-m-dT',
973                            (string)$request->expiry_date
974                        ) : null;
975                    $lastInterestDate = null;
976                }
977                $requestStatus = (string)$request->request_status;
978                $updateDetails = (!$available || $allowCancelingAvailableRequests)
979                    ? (string)$request->request_id : '';
980
981                $hold = [
982                    'create' => $this->parseDate((string)$request->request_time),
983                    'expire' => $lastInterestDate,
984                    'id' => (string)($request->mms_id ?? ''),
985                    'reqnum' => (string)$request->request_id,
986                    'available' => $available,
987                    'last_pickup_date' => $lastPickupDate,
988                    'item_id' => (string)$request->request_id,
989                    'location' => (string)$request->pickup_location,
990                    'processed' => $request->item_policy === 'InterlibraryLoan'
991                        && $requestStatus !== 'Not Started',
992                    'title' => (string)$request->title,
993                    'cancel_details' => $updateDetails,
994                    'updateDetails' => $updateDetails,
995                ];
996                if (!$available) {
997                    $hold['position'] = 'In Process' === $requestStatus
998                        ? $this->translate('hold_in_process')
999                        : (int)($request->place_in_queue ?? 1);
1000                }
1001
1002                $holdList[] = $hold;
1003            }
1004        }
1005        return $holdList;
1006    }
1007
1008    /**
1009     * Cancel hold requests.
1010     *
1011     * @param array $cancelDetails An associative array with two keys: patron
1012     *                             (array returned by the driver's
1013     *                             patronLogin method) and details (an array
1014     *                             of strings returned in holds' cancel_details
1015     *                             field.
1016     *
1017     * @return array                Associative array containing with keys 'count'
1018     *                                 (number of items successfully cancelled) and
1019     *                                 'items' (array of successful cancellations).
1020     */
1021    public function cancelHolds($cancelDetails)
1022    {
1023        $returnArray = [];
1024        $patronId = $cancelDetails['patron']['id'];
1025        $count = 0;
1026
1027        foreach ($cancelDetails['details'] as $requestId) {
1028            $item = [];
1029            try {
1030                // Delete the request in Alma
1031                $apiResult = $this->makeRequest(
1032                    $this->baseUrl .
1033                    '/users/' . rawurlencode($patronId) .
1034                    '/requests/' . rawurlencode($requestId),
1035                    ['reason' => 'CancelledAtPatronRequest'],
1036                    [],
1037                    'DELETE'
1038                );
1039
1040                // Adding to "count" variable and setting values to return array
1041                $count++;
1042                $item[$requestId] = [
1043                    'success' => true,
1044                    'status' => 'hold_cancel_success',
1045                ];
1046            } catch (ILSException $e) {
1047                if (isset($apiResult['xml'])) {
1048                    $almaErrorCode = $apiResult['xml']->errorList->error->errorCode;
1049                    $sysMessage = $apiResult['xml']->errorList->error->errorMessage;
1050                } else {
1051                    $almaErrorCode = 'No error code available';
1052                    $sysMessage = 'HTTP status code: ' .
1053                         ($e->getCode() ?? 'Code not available');
1054                }
1055                $item[$requestId] = [
1056                    'success' => false,
1057                    'status' => 'hold_cancel_fail',
1058                    'sysMessage' => $sysMessage . '. ' .
1059                         'Alma request ID: ' . $requestId . '. ' .
1060                         'Alma error code: ' . $almaErrorCode,
1061                ];
1062            }
1063
1064            $returnArray['items'] = $item;
1065        }
1066
1067        $returnArray['count'] = $count;
1068
1069        return $returnArray;
1070    }
1071
1072    /**
1073     * Update holds
1074     *
1075     * This is responsible for changing the status of hold requests
1076     *
1077     * @param array $holdsDetails The details identifying the holds
1078     * @param array $fields       An associative array of fields to be updated
1079     * @param array $patron       Patron array
1080     *
1081     * @return array Associative array of the results
1082     */
1083    public function updateHolds(
1084        array $holdsDetails,
1085        array $fields,
1086        array $patron
1087    ): array {
1088        $results = [];
1089        $patronId = $patron['id'];
1090        foreach ($holdsDetails as $requestId) {
1091            $requestUrl = $this->baseUrl . '/users/' . rawurlencode($patronId)
1092                . '/requests/' . rawurlencode($requestId);
1093            $requestDetails = $this->makeRequest($requestUrl);
1094
1095            if (isset($fields['pickUpLocation'])) {
1096                $requestDetails->pickup_location_library = $fields['pickUpLocation'];
1097            }
1098            [$result, $status] = $this->makeRequest(
1099                $requestUrl,
1100                [],
1101                [],
1102                'PUT',
1103                $requestDetails->asXML(),
1104                ['Content-Type' => 'application/xml'],
1105                [400],
1106                true
1107            );
1108            if (200 != $status) {
1109                $error = $result->errorList->error[0]->errorMessage
1110                    ?? 'hold_error_fail';
1111                $results[$requestId] = [
1112                    'success' => false,
1113                    'status' => (string)$error,
1114                ];
1115            } else {
1116                $results[$requestId] = [
1117                    'success' => true,
1118                ];
1119            }
1120        }
1121
1122        return $results;
1123    }
1124
1125    /**
1126     * Get Patron Storage Retrieval Requests
1127     *
1128     * This is responsible for retrieving all call slips by a specific patron.
1129     *
1130     * @param array $patron The patron array from patronLogin
1131     *
1132     * @return mixed        Array of the patron's holds
1133     *
1134     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1135     */
1136    public function getMyStorageRetrievalRequests($patron)
1137    {
1138        $xml = $this->makeRequest(
1139            '/users/' . rawurlencode($patron['id']) . '/requests',
1140            ['request_type' => 'MOVE']
1141        );
1142        $holdList = [];
1143        for ($i = 0; $i < count($xml->user_requests); $i++) {
1144            $request = $xml->user_requests[$i];
1145            if (
1146                !isset($request->item_policy)
1147                || $request->item_policy !== 'Archive'
1148            ) {
1149                continue;
1150            }
1151            $holdList[] = [
1152                'create' => $request->request_date,
1153                'expire' => $request->last_interest_date,
1154                'id' => $request->request_id,
1155                'in_transit' => $request->request_status !== 'IN_PROCESS',
1156                'item_id' => $request->mms_id,
1157                'location' => $request->pickup_location,
1158                'processed' => $request->item_policy === 'InterlibraryLoan'
1159                    && $request->request_status !== 'NOT_STARTED',
1160                'title' => $request->title,
1161            ];
1162        }
1163        return $holdList;
1164    }
1165
1166    /**
1167     * Get Patron ILL Requests
1168     *
1169     * This is responsible for retrieving all ILL requests by a specific patron.
1170     *
1171     * @param array $patron The patron array from patronLogin
1172     *
1173     * @return mixed        Array of the patron's ILL requests
1174     *
1175     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1176     */
1177    public function getMyILLRequests($patron)
1178    {
1179        $xml = $this->makeRequest(
1180            '/users/' . rawurlencode($patron['id']) . '/requests',
1181            ['request_type' => 'MOVE']
1182        );
1183        $holdList = [];
1184        for ($i = 0; $i < count($xml->user_requests); $i++) {
1185            $request = $xml->user_requests[$i];
1186            if (
1187                !isset($request->item_policy)
1188                || $request->item_policy !== 'InterlibraryLoan'
1189            ) {
1190                continue;
1191            }
1192            $holdList[] = [
1193                'create' => $request->request_date,
1194                'expire' => $request->last_interest_date,
1195                'id' => $request->request_id,
1196                'in_transit' => $request->request_status !== 'IN_PROCESS',
1197                'item_id' => $request->mms_id,
1198                'location' => $request->pickup_location,
1199                'processed' => $request->item_policy === 'InterlibraryLoan'
1200                    && $request->request_status !== 'NOT_STARTED',
1201                'title' => $request->title,
1202            ];
1203        }
1204        return $holdList;
1205    }
1206
1207    /**
1208     * Get transactions of the current patron.
1209     *
1210     * @param array $patron The patron array from patronLogin
1211     * @param array $params Parameters
1212     *
1213     * @return array Transaction information as array.
1214     *
1215     * @author Michael Birkner
1216     */
1217    public function getMyTransactions($patron, $params = [])
1218    {
1219        // Defining the return value
1220        $returnArray = [];
1221
1222        // Get the patron id
1223        $patronId = $patron['id'];
1224
1225        // Create a timestamp for calculating the due / overdue status
1226        $nowTS = time();
1227
1228        $sort = explode(
1229            ' ',
1230            !empty($params['sort']) ? $params['sort'] : 'checkout desc',
1231            2
1232        );
1233        if ($sort[0] == 'checkout') {
1234            $sortKey = 'loan_date';
1235        } elseif ($sort[0] == 'title') {
1236            $sortKey = 'title';
1237        } else {
1238            $sortKey = 'due_date';
1239        }
1240        $direction = (isset($sort[1]) && 'desc' === $sort[1]) ? 'DESC' : 'ASC';
1241
1242        $pageSize = $params['limit'] ?? 50;
1243        $params = [
1244            'limit' => $pageSize,
1245            'offset' => isset($params['page'])
1246                ? ($params['page'] - 1) * $pageSize : 0,
1247            'order_by' => $sortKey,
1248            'direction' => $direction,
1249            'expand' => 'renewable',
1250        ];
1251
1252        // Get user loans from Alma API
1253        $apiResult = $this->makeRequest(
1254            '/users/' . rawurlencode($patronId) . '/loans',
1255            $params
1256        );
1257
1258        // If there is an API result, process it
1259        $totalCount = 0;
1260        if ($apiResult) {
1261            $totalCount = $apiResult->attributes()->total_record_count;
1262            // Iterate over all item loans
1263            foreach ($apiResult->item_loan as $itemLoan) {
1264                $loan['duedate'] = $this->parseDate(
1265                    (string)$itemLoan->due_date,
1266                    true
1267                );
1268                //$loan['dueTime'] = ;
1269                $loan['dueStatus'] = null; // Calculated below
1270                $loan['id'] = (string)$itemLoan->mms_id;
1271                //$loan['source'] = 'Solr';
1272                $loan['barcode'] = (string)$itemLoan->item_barcode;
1273                //$loan['renew'] = ;
1274                //$loan['renewLimit'] = ;
1275                //$loan['request'] = ;
1276                //$loan['volume'] = ;
1277                $loan['publication_year'] = (string)$itemLoan->publication_year;
1278                $loan['renewable']
1279                    = (strtolower((string)$itemLoan->renewable) == 'true')
1280                    ? true
1281                    : false;
1282                //$loan['message'] = ;
1283                $loan['title'] = (string)$itemLoan->title;
1284                $loan['item_id'] = (string)$itemLoan->loan_id;
1285                $loan['institution_name']
1286                    = $this->getTranslatableString($itemLoan->library);
1287                //$loan['isbn'] = ;
1288                //$loan['issn'] = ;
1289                //$loan['oclc'] = ;
1290                //$loan['upc'] = ;
1291                $loan['borrowingLocation']
1292                    = $this->getTranslatableString($itemLoan->circ_desk);
1293
1294                // Calculate due status
1295                $dueDateTS = strtotime($loan['duedate']);
1296                if ($nowTS > $dueDateTS) {
1297                    // Loan is overdue
1298                    $loan['dueStatus'] = 'overdue';
1299                } elseif (($dueDateTS - $nowTS) < 86400) {
1300                    // Due date within one day
1301                    $loan['dueStatus'] = 'due';
1302                }
1303
1304                $returnArray[] = $loan;
1305            }
1306        }
1307
1308        return [
1309            'count' => $totalCount,
1310            'records' => $returnArray,
1311        ];
1312    }
1313
1314    /**
1315     * Get Alma loan IDs for use in renewMyItems.
1316     *
1317     * @param array $checkOutDetails An array from getMyTransactions
1318     *
1319     * @return string The Alma loan ID for this loan
1320     *
1321     * @author Michael Birkner
1322     */
1323    public function getRenewDetails($checkOutDetails)
1324    {
1325        $loanId = $checkOutDetails['item_id'];
1326        return $loanId;
1327    }
1328
1329    /**
1330     * Renew loans via Alma API.
1331     *
1332     * @param array $renewDetails An array with the IDs of the loans returned by
1333     *                            getRenewDetails and the patron information
1334     *                            returned by patronLogin.
1335     *
1336     * @return array[] An array with the renewal details and a success or error
1337     *                 message.
1338     *
1339     * @author Michael Birkner
1340     */
1341    public function renewMyItems($renewDetails)
1342    {
1343        $returnArray = [];
1344        $patronId = $renewDetails['patron']['id'];
1345
1346        foreach ($renewDetails['details'] as $loanId) {
1347            // Create an empty array that holds the information for a renewal
1348            $renewal = [];
1349
1350            try {
1351                // POST the renewals to Alma
1352                $apiResult = $this->makeRequest(
1353                    '/users/' . rawurlencode($patronId) . '/loans/'
1354                    . rawurlencode($loanId) . '/?op=renew',
1355                    [],
1356                    [],
1357                    'POST'
1358                );
1359
1360                // Add information to the renewal array
1361                $renewal = [
1362                    'success' => true,
1363                    'new_date' => $this->parseDate(
1364                        (string)$apiResult->due_date,
1365                        true
1366                    ),
1367                    'item_id' => (string)$apiResult->loan_id,
1368                    'sysMessage' => 'renew_success',
1369                ];
1370
1371                // Add the renewal to the return array
1372                $returnArray['details'][$loanId] = $renewal;
1373            } catch (ILSException $ilsEx) {
1374                // Add the empty renewal array to the return array
1375                $returnArray['details'][$loanId] = [
1376                    'success' => false,
1377                ];
1378            }
1379        }
1380
1381        return $returnArray;
1382    }
1383
1384    /**
1385     * Get Status
1386     *
1387     * This is responsible for retrieving the status information of a certain
1388     * record.
1389     *
1390     * @param string $id The record id to retrieve the holdings for
1391     *
1392     * @return mixed     On success, an associative array with the following keys:
1393     * id, availability (boolean), status, location, reserve, callnumber.
1394     */
1395    public function getStatus($id)
1396    {
1397        $idList = [$id];
1398        $status = $this->getStatuses($idList);
1399        return current($status);
1400    }
1401
1402    /**
1403     * Get Statuses
1404     *
1405     * This is responsible for retrieving the status information for a
1406     * collection of records.
1407     *
1408     * @param array $ids The array of record ids to retrieve the status for
1409     *
1410     * @return array An array of getStatus() return values on success.
1411     */
1412    public function getStatuses($ids)
1413    {
1414        return $this->getStatusesForInventoryTypes($ids, $this->getInventoryTypes());
1415    }
1416
1417    /**
1418     * Get Purchase History
1419     *
1420     * This is responsible for retrieving the acquisitions history data for the
1421     * specific record (usually recently received issues of a serial).
1422     *
1423     * @param string $id The record id to retrieve the info for
1424     *
1425     * @return array     An array with the acquisitions data on success.
1426     */
1427    public function getPurchaseHistory($id)
1428    {
1429        // TODO: Alma getPurchaseHistory
1430        return [];
1431    }
1432
1433    /**
1434     * Public Function which retrieves renew, hold and cancel settings from the
1435     * driver ini file.
1436     *
1437     * @param string $function The name of the feature to be checked
1438     * @param array  $params   Optional feature-specific parameters (array)
1439     *
1440     * @return array An array with key-value pairs.
1441     *
1442     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1443     */
1444    public function getConfig($function, $params = [])
1445    {
1446        if ($function == 'patronLogin') {
1447            return [
1448                'loginMethod' => $this->config['Catalog']['loginMethod'] ?? 'vufind',
1449            ];
1450        }
1451        if (isset($this->config[$function])) {
1452            $functionConfig = $this->config[$function];
1453
1454            // Set default value for "itemLimit" in Alma driver
1455            if ($function === 'Holdings') {
1456                // Use itemLimit in Holds as fallback for backward compatibility
1457                $functionConfig['itemLimit'] = ($functionConfig['itemLimit']
1458                    ?? $this->config['Holds']['itemLimit']
1459                    ?? 10) ?: 10;
1460            }
1461        } elseif ('getMyTransactions' === $function) {
1462            $functionConfig = [
1463                'max_results' => 100,
1464                'sort' => [
1465                    'checkout desc' => 'sort_checkout_date_desc',
1466                    'checkout asc' => 'sort_checkout_date_asc',
1467                    'due desc' => 'sort_due_date_desc',
1468                    'due asc' => 'sort_due_date_asc',
1469                    'title asc' => 'sort_title',
1470                ],
1471                'default_sort' => 'due asc',
1472            ];
1473        } else {
1474            $functionConfig = false;
1475        }
1476
1477        return $functionConfig;
1478    }
1479
1480    /**
1481     * Place a hold request via Alma API. This could be a title level request or
1482     * an item level request.
1483     *
1484     * @param array $holdDetails An associative array w/ atleast patron and item_id
1485     *
1486     * @return array success: bool, sysMessage: string
1487     *
1488     * @link https://developers.exlibrisgroup.com/alma/apis/bibs
1489     */
1490    public function placeHold($holdDetails)
1491    {
1492        // Check for title or item level request
1493        $level = $holdDetails['level'] ?? 'item';
1494
1495        // Get information that is valid for both, item level requests and title
1496        // level requests.
1497        $mmsId = $holdDetails['id'];
1498        // The holding_id value is deprecated but retained for back-compatibility
1499        $holId = $holdDetails['holdings_id'] ?? $holdDetails['holding_id'];
1500        $itmId = $holdDetails['item_id'];
1501        $patronId = $holdDetails['patron']['id'];
1502        $pickupLocation = $holdDetails['pickUpLocation'] ?? null;
1503        $comment = $holdDetails['comment'] ?? null;
1504        $requiredBy = (isset($holdDetails['requiredBy']))
1505        ? $this->dateConverter->convertFromDisplayDate(
1506            'Y-m-d',
1507            $holdDetails['requiredBy']
1508        ) . 'Z'
1509        : null;
1510
1511        // Create body for API request
1512        $body = [];
1513        $body['request_type'] = 'HOLD';
1514        $body['pickup_location_type'] = 'LIBRARY';
1515        $body['pickup_location_library'] = $pickupLocation;
1516        $body['comment'] = $comment;
1517        $body['last_interest_date'] = $requiredBy;
1518
1519        // Remove "null" values from body array
1520        $body = array_filter($body);
1521
1522        // Check if we have a title level request or an item level request
1523        if ($level === 'title') {
1524            // Add description if we have one for title level requests as Alma
1525            // needs it under certain circumstances. See: https://developers.
1526            // exlibrisgroup.com/alma/apis/xsd/rest_user_request.xsd?tags=POST
1527            $description = isset($holdDetails['description']) ?? null;
1528            if ($description) {
1529                $body['description'] = $description;
1530            }
1531
1532            // Create HTTP client with Alma API URL for title level requests
1533            $client = $this->httpService->createClient(
1534                $this->baseUrl . '/bibs/' . rawurlencode($mmsId)
1535                . '/requests?apikey=' . urlencode($this->apiKey)
1536                . '&user_id=' . urlencode($patronId)
1537                . '&format=json'
1538            );
1539        } else {
1540            // Create HTTP client with Alma API URL for item level requests
1541            $client = $this->httpService->createClient(
1542                $this->baseUrl . '/bibs/' . rawurlencode($mmsId)
1543                . '/holdings/' . rawurlencode($holId)
1544                . '/items/' . rawurlencode($itmId)
1545                . '/requests?apikey=' . urlencode($this->apiKey)
1546                . '&user_id=' . urlencode($patronId)
1547                . '&format=json'
1548            );
1549        }
1550
1551        // Set headers
1552        $client->setHeaders(
1553            [
1554            'Content-type: application/json',
1555            'Accept: application/json',
1556            ]
1557        );
1558
1559        // Set HTTP method
1560        $client->setMethod(\Laminas\Http\Request::METHOD_POST);
1561
1562        // Set body
1563        $client->setRawBody(json_encode($body));
1564
1565        // Send API call and get response
1566        $response = $client->send();
1567
1568        // Check for success
1569        if ($response->isSuccess()) {
1570            return ['success' => true];
1571        } else {
1572            $url = $client->getRequest()->getUriString();
1573            $statusCode = $response->getStatusCode();
1574            $this->logError(
1575                "Alma error for hold POST request '$url' (status code $statusCode): "
1576                . $response->getBody()
1577            );
1578        }
1579
1580        // Get error message
1581        $error = json_decode($response->getBody());
1582        if (!$error) {
1583            $error = simplexml_load_string($response->getBody());
1584        }
1585
1586        return [
1587            'success' => false,
1588            'sysMessage' => $error->errorList->error[0]->errorMessage
1589                ?? 'hold_error_fail',
1590        ];
1591    }
1592
1593    /**
1594     * Get Pick Up Locations
1595     *
1596     * This is responsible get a list of valid library locations for holds / recall
1597     * retrieval
1598     *
1599     * @param array $patron      Patron information returned by the patronLogin
1600     * method.
1601     * @param array $holdDetails Optional array, only passed in when getting a list
1602     * in the context of placing or editing a hold. When placing a hold, it contains
1603     * most of the same values passed to placeHold, minus the patron data. When
1604     * editing a hold it contains all the hold information returned by getMyHolds.
1605     * May be used to limit the pickup options or may be ignored. The driver must
1606     * not add new options to the return array based on this data or other areas of
1607     * VuFind may behave incorrectly.
1608     *
1609     * @return array An array of associative arrays with locationID and
1610     * locationDisplay keys
1611     *
1612     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1613     */
1614    public function getPickupLocations($patron, $holdDetails = null)
1615    {
1616        $xml = $this->makeRequest('/conf/libraries');
1617        $libraries = [];
1618        foreach ($xml as $library) {
1619            $libraries[] = [
1620                'locationID' => (string)$library->code,
1621                'locationDisplay' => (string)$library->name,
1622            ];
1623        }
1624        return $libraries;
1625    }
1626
1627    /**
1628     * Request from /courses.
1629     *
1630     * @return array with key = course ID, value = course name
1631     */
1632    public function getCourses()
1633    {
1634        // https://developers.exlibrisgroup.com/alma/apis/courses
1635        // GET /almaws/v1/courses
1636        $xml = $this->makeRequest('/courses');
1637        $courses = [];
1638        foreach ($xml as $course) {
1639            $courses[$course->id] = $course->name;
1640        }
1641        return $courses;
1642    }
1643
1644    /**
1645     * Get reserves by course
1646     *
1647     * @param string $courseID     Value from getCourses
1648     * @param string $instructorID Value from getInstructors (not used yet)
1649     * @param string $departmentID Value from getDepartments (not used yet)
1650     *
1651     * @return array With key BIB_ID - The record ID of the current reserve item.
1652     *               Not currently used:
1653     *               DISPLAY_CALL_NO, AUTHOR, TITLE, PUBLISHER, PUBLISHER_DATE
1654     *
1655     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1656     */
1657    public function findReserves($courseID, $instructorID, $departmentID)
1658    {
1659        // https://developers.exlibrisgroup.com/alma/apis/courses
1660        // GET /almaws/v1/courses/{course_id}/reading-lists
1661        $listsBase = '/courses/' . rawurlencode($courseID) . '/reading-lists';
1662        $xml = $this->makeRequest($listsBase);
1663        $reserves = [];
1664        foreach ($xml as $list) {
1665            $listId = $list->id;
1666            $listXML = $this->makeRequest(
1667                $listsBase . '/' . rawurlencode($listId) . '/citations'
1668            );
1669            foreach ($listXML as $citation) {
1670                $reserves[$citation->id] = $citation->metadata;
1671            }
1672        }
1673        return $reserves;
1674    }
1675
1676    /**
1677     * Parse a date.
1678     *
1679     * @param string  $date     Date to parse
1680     * @param boolean $withTime Add time to return if available?
1681     *
1682     * @return string
1683     */
1684    public function parseDate($date, $withTime = false)
1685    {
1686        // Remove trailing Z from end of date
1687        // e.g. from Alma we get dates like 2012-07-13Z without time, which is wrong)
1688        if (!str_contains($date, 'T') && str_ends_with($date, 'Z')) {
1689            $date = substr($date, 0, -1);
1690        }
1691
1692        $compactDate = '/^[0-9]{8}$/'; // e. g. 20120725
1693        $euroName = "/^[0-9]+\/[A-Za-z]{3}\/[0-9]{4}$/"; // e. g. 13/jan/2012
1694        $euro = "/^[0-9]+\/[0-9]+\/[0-9]{4}$/"; // e. g. 13/7/2012
1695        $euroPad = "/^[0-9]{1,2}\/[0-9]{1,2}\/[0-9]{2,4}$/"; // e. g. 13/07/2012
1696        $datestamp = '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/'; // e. g. 2012-07-13
1697        $timestamp = '/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/';
1698        $timestampMs
1699            = '/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z$/';
1700        // e. g. 2017-07-09T18:00:00
1701
1702        if ($date == null || $date == '') {
1703            return '';
1704        } elseif (preg_match($compactDate, $date) === 1) {
1705            return $this->dateConverter->convertToDisplayDate('Ynd', $date);
1706        } elseif (preg_match($euroName, $date) === 1) {
1707            return $this->dateConverter->convertToDisplayDate('d/M/Y', $date);
1708        } elseif (preg_match($euro, $date) === 1) {
1709            return $this->dateConverter->convertToDisplayDate('d/m/Y', $date);
1710        } elseif (preg_match($euroPad, $date) === 1) {
1711            return $this->dateConverter->convertToDisplayDate('d/m/y', $date);
1712        } elseif (preg_match($datestamp, $date) === 1) {
1713            return $this->dateConverter->convertToDisplayDate('Y-m-d', $date);
1714        } elseif (preg_match($timestamp, $date) === 1) {
1715            if ($withTime) {
1716                return $this->dateConverter->convertToDisplayDateAndTime(
1717                    'Y-m-d\TH:i:sT',
1718                    $date
1719                );
1720            } else {
1721                return $this->dateConverter->convertToDisplayDate(
1722                    'Y-m-d',
1723                    substr($date, 0, 10)
1724                );
1725            }
1726        } elseif (preg_match($timestampMs, $date) === 1) {
1727            if ($withTime) {
1728                return $this->dateConverter->convertToDisplayDateAndTime(
1729                    'Y-m-d\TH:i:s#???T',
1730                    $date
1731                );
1732            } else {
1733                return $this->dateConverter->convertToDisplayDate(
1734                    'Y-m-d',
1735                    substr($date, 0, 10)
1736                );
1737            }
1738        } else {
1739            throw new \Exception("Invalid date: $date");
1740        }
1741    }
1742
1743    /**
1744     * Helper method to determine whether or not a certain method can be
1745     * called on this driver. Required method for any smart drivers.
1746     *
1747     * @param string $method The name of the called method.
1748     * @param array  $params Array of passed parameters
1749     *
1750     * @return bool True if the method can be called with the given parameters,
1751     * false otherwise.
1752     *
1753     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1754     */
1755    public function supportsMethod($method, $params)
1756    {
1757        return is_callable([$this, $method]);
1758    }
1759
1760    /**
1761     * Get the inventory types to be displayed. Possible values are:
1762     * p_avail,e_avail,d_avail
1763     *
1764     * @return array
1765     */
1766    protected function getInventoryTypes()
1767    {
1768        $types = explode(
1769            ':',
1770            $this->config['Holdings']['inventoryTypes']
1771                ?? 'physical:digital:electronic'
1772        );
1773
1774        $result = [];
1775        $map = [
1776            'physical' => 'p_avail',
1777            'digital' => 'd_avail',
1778            'electronic' => 'e_avail',
1779        ];
1780        $types = array_flip($types);
1781        foreach ($map as $src => $dest) {
1782            if (isset($types[$src])) {
1783                $result[] = $dest;
1784            }
1785        }
1786
1787        return $result;
1788    }
1789
1790    /**
1791     * Get Statuses for inventory types
1792     *
1793     * This is responsible for retrieving the status information for a
1794     * collection of records with specified inventory types.
1795     *
1796     * @param array $ids   The array of record ids to retrieve the status for
1797     * @param array $types Inventory types
1798     *
1799     * @return array An array of getStatus() return values on success.
1800     */
1801    protected function getStatusesForInventoryTypes($ids, $types)
1802    {
1803        $results = [];
1804        $params = [
1805            'mms_id' => implode(',', $ids),
1806            'expand' => implode(',', $types),
1807        ];
1808        if ($bibs = $this->makeRequest('/bibs', $params)) {
1809            foreach ($bibs as $bib) {
1810                $marc = new MarcReader($bib->record->asXML());
1811                $status = [];
1812                $tmpl = [
1813                    'id' => (string)$bib->mms_id,
1814                    'source' => 'Solr',
1815                    'callnumber' => '',
1816                    'reserve' => 'N',
1817                ];
1818                // Physical
1819                $physicalItems = $marc->getFields('AVA');
1820                foreach ($physicalItems as $field) {
1821                    $available = null;
1822                    $statusText = '';
1823                    if ($this->locationTypeToItemStatus) {
1824                        $locationCode = $marc->getSubfield($field, 'j');
1825                        $library = $marc->getSubfield($field, 'b');
1826                        [$available, $statusText] = $this->getItemStatusFromLocationTypeMap(
1827                            $this->getLocationType($library, $locationCode)
1828                        );
1829                    }
1830
1831                    if (null === $available) {
1832                        $availStr = strtolower($marc->getSubfield($field, 'e'));
1833                        $available = 'available' === $availStr;
1834                        // No status message available, so set it based on availability:
1835                        $statusText = $available ? 'Item in place' : 'Item not in place';
1836                    }
1837
1838                    $item = $tmpl;
1839                    $item['availability'] = $available;
1840                    $item['status'] = $statusText;
1841                    $item['location'] = $marc->getSubfield($field, 'c');
1842                    $item['callnumber'] = $marc->getSubfield($field, 'd');
1843                    $status[] = $item;
1844                }
1845                // Electronic
1846                $electronicItems = $marc->getFields('AVE');
1847                foreach ($electronicItems as $field) {
1848                    $avail = $marc->getSubfield($field, 'e');
1849                    $item = $tmpl;
1850                    $item['availability'] = strtolower($avail) === 'available';
1851                    // Use the following subfields for location:
1852                    // m (Collection name)
1853                    // i (Available for library)
1854                    // d (Available for library)
1855                    // b (Available for library)
1856                    $location = [$marc->getSubfield($field, 'm') ?: 'Get full text'];
1857                    foreach (['i', 'd', 'b'] as $code) {
1858                        if ($content = $marc->getSubfield($field, $code)) {
1859                            $location[] = $content;
1860                        }
1861                    }
1862                    $item['location'] = implode(' - ', $location);
1863                    $item['callnumber'] = $marc->getSubfield($field, 't');
1864                    $url = $marc->getSubfield($field, 'u');
1865                    if (preg_match('/^https?:\/\//', $url)) {
1866                        $item['locationhref'] = $url;
1867                    }
1868                    $item['status'] = $marc->getSubfield($field, 's') ?: null;
1869                    if ($note = $marc->getSubfield($field, 'n')) {
1870                        $item['item_notes'] = [$note];
1871                    }
1872                    $status[] = $item;
1873                }
1874                // Digital
1875                $deliveryUrl
1876                    = $this->config['Holdings']['digitalDeliveryUrl'] ?? '';
1877                $digitalItems = $marc->getFields('AVD');
1878                if ($digitalItems && !$deliveryUrl) {
1879                    $this->logWarning(
1880                        'Digital items exist for ' . (string)$bib->mms_id
1881                        . ', but digitalDeliveryUrl not set -- unable to'
1882                        . ' generate links'
1883                    );
1884                }
1885                foreach ($digitalItems as $field) {
1886                    $item = $tmpl;
1887                    unset($item['callnumber']);
1888                    $item['availability'] = true;
1889                    $item['location'] = $marc->getSubfield($field, 'e');
1890                    // Using subfield 'd' ('Repository Name') as callnumber
1891                    $item['callnumber'] = $marc->getSubfield($field, 'd');
1892                    if ($deliveryUrl) {
1893                        $item['locationhref'] = str_replace(
1894                            '%%id%%',
1895                            $marc->getSubfield($field, 'b'),
1896                            $deliveryUrl
1897                        );
1898                    }
1899                    $status[] = $item;
1900                }
1901                $results[(string)$bib->mms_id] = $status;
1902            }
1903        }
1904        return $results;
1905    }
1906
1907    /**
1908     * Get the preferred email address for the user (or first one if no preferred one
1909     * is found)
1910     *
1911     * @param SimpleXMLElement $user User data
1912     *
1913     * @return string|null
1914     */
1915    protected function getPreferredEmail($user)
1916    {
1917        if (!empty($user->contact_info->emails->email)) {
1918            foreach ($user->contact_info->emails->email as $email) {
1919                if ('true' === (string)$email['preferred']) {
1920                    return isset($email->email_address)
1921                        ? trim((string)$email->email_address) : null;
1922                }
1923            }
1924            $email = $user->contact_info->emails->email[0];
1925            return isset($email->email_address)
1926                ? (string)$email->email_address : null;
1927        }
1928        return null;
1929    }
1930
1931    /**
1932     * Gets a translatable string from an element with content and a desc attribute.
1933     *
1934     * @param SimpleXMLElement $element XML element
1935     *
1936     * @return \VuFind\I18n\TranslatableString
1937     */
1938    protected function getTranslatableString($element)
1939    {
1940        if (null === $element) {
1941            return null;
1942        }
1943        $value = ($this->config['Catalog']['translationPrefix'] ?? '')
1944            . (string)$element;
1945        $desc = (string)($element->attributes()->desc ?? $element);
1946        return new TranslatableString($value, $desc);
1947    }
1948
1949    /**
1950     * Gets a translatable string from an element with content and a desc attribute.
1951     *
1952     * @param SimpleXMLElement $element XML element
1953     *
1954     * @return TranslatableString
1955     */
1956    protected function getTranslatableStatusString($element)
1957    {
1958        if (null === $element) {
1959            return null;
1960        }
1961        $value = 'status_' . strtolower((string)$element);
1962        $desc = (string)($element->attributes()->desc ?? $element);
1963        return new TranslatableString($value, $desc);
1964    }
1965
1966    /**
1967     * Get location for an item
1968     *
1969     * @param SimpleXMLElement $item Item
1970     *
1971     * @return TranslatableString|string
1972     */
1973    protected function getItemLocation($item)
1974    {
1975        return $this->getTranslatableString($item->item_data->location);
1976    }
1977
1978    /**
1979     * Get location type for an item
1980     *
1981     * @param SimpleXMLElement $item Item
1982     *
1983     * @return string
1984     */
1985    protected function getItemLocationType($item)
1986    {
1987        // Yes, temporary location is in holding data while permanent location is in
1988        // item data.
1989        if ('true' === (string)$item->holding_data->in_temp_location) {
1990            $library = $item->holding_data->temp_library
1991                ?: $item->item_data->library;
1992            $location = $item->holding_data->temp_location
1993                ?: $item->item_data->location;
1994        } else {
1995            $library = $item->item_data->library;
1996            $location = $item->item_data->location;
1997        }
1998        return $this->getLocationType((string)$library, (string)$location);
1999    }
2000
2001    /**
2002     * Get type of a location
2003     *
2004     * @param string $library  Library
2005     * @param string $location Location
2006     *
2007     * @return string
2008     */
2009    protected function getLocationType($library, $location)
2010    {
2011        $locations = $this->getLocations($library);
2012        return $locations[$location]['type'] ?? '';
2013    }
2014
2015    /**
2016     * Get the locations for a library
2017     *
2018     * @param string $library Library
2019     *
2020     * @return array
2021     */
2022    protected function getLocations($library)
2023    {
2024        $cacheId = 'alma|locations|' . $library;
2025        $locations = $this->getCachedData($cacheId);
2026
2027        if (null === $locations) {
2028            $xml = $this->makeRequest(
2029                '/conf/libraries/' . rawurlencode($library) . '/locations'
2030            );
2031            $locations = [];
2032            foreach ($xml as $entry) {
2033                $locations[(string)$entry->code] = [
2034                    'name' => (string)$entry->name,
2035                    'externalName' => (string)$entry->external_name,
2036                    'type' => (string)$entry->type,
2037                ];
2038            }
2039            $this->putCachedData($cacheId, $locations, 3600);
2040        }
2041        return $locations;
2042    }
2043
2044    /**
2045     * Get list of funds
2046     *
2047     * @return array with key = course ID, value = course name
2048     */
2049    public function getFunds()
2050    {
2051        // TODO: implement me!
2052        // https://developers.exlibrisgroup.com/alma/apis/acq
2053        // GET /almaws/v1/acq/funds
2054        return [];
2055    }
2056}