Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
1.09% covered (danger)
1.09%
17 / 1559
5.19% covered (danger)
5.19%
4 / 77
CRAP
0.00% covered (danger)
0.00%
0 / 1
SierraRest
1.09% covered (danger)
1.09%
17 / 1559
5.19% covered (danger)
5.19%
4 / 77
171926.86
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
 setConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init
3.57% covered (danger)
3.57%
2 / 56
0.00% covered (danger)
0.00%
0 / 1
119.49
 getInnReachDb
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getStatus
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStatuses
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getHolding
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
 getNewItems
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 findReserves
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 patronLogin
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
42
 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 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMyProfile
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
72
 getMyTransactions
0.00% covered (danger)
0.00%
0 / 64
0.00% covered (danger)
0.00%
0 / 1
210
 getRenewDetails
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 renewMyItems
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
20
 getMyTransactionHistory
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
132
 purgeTransactionHistory
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
42
 getMyHolds
0.00% covered (danger)
0.00%
0 / 110
0.00% covered (danger)
0.00%
0 / 1
1122
 cancelHolds
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
12
 updateHolds
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
182
 getPickUpLocations
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
42
 getDefaultPickUpLocation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 checkRequestIsValid
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
56
 placeHold
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
182
 getMyFines
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
90
 changePassword
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
20
 getConfig
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 supportsMethod
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 extractId
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 extractVolume
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 makeRequest
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
30
 requestCallback
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 1
380
 getApiUrlFromHierarchy
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 renewAccessToken
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
72
 getPatronAuthorizationCode
0.00% covered (danger)
0.00%
0 / 76
0.00% covered (danger)
0.00%
0 / 1
240
 createHttpClient
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 formatCacheKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getBibCallNumber
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getDueStatus
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 getItemStatusesForBib
0.00% covered (danger)
0.00%
0 / 120
0.00% covered (danger)
0.00%
0 / 1
650
 extractCallNumber
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getOrderMessages
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 getHoldingsData
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
72
 extractFieldsFromApiData
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
182
 getLocationName
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
30
 translateLocation
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 statusSortFunction
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 translateOpacMessage
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 mapStatusCode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getItemStatus
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
72
 isHoldable
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
210
 getPatronBlocks
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 pickupLocationSortFunction
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 pickUpLocationIsValid
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 holdError
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 formatErrorMessage
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getCachedRecordData
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 putCachedRecordData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getBibRecord
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getRecords
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
30
 getBibRecords
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getItemRecords
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getItemsForBibRecord
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
72
 extractBibId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 formatBibId
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 isPatronSpecificAccess
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPatronInformationFromAuthToken
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
30
 authenticatePatron
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
42
 validatePatron
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 authenticatePatronV5
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
56
 authenticatePatronV6
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 getItemsWithBibsForTransactions
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
132
 checkTitleHoldRules
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
210
 getInnReachHoldTitleInfoFromId
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 getInnReachCheckoutTitleInfoFromId
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3/**
4 * III Sierra REST API driver
5 *
6 * PHP version 8
7 *
8 * Copyright (C) The National Library of Finland 2016-2024.
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   Ere Maijala <ere.maijala@helsinki.fi>
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\Log\LoggerAwareInterface;
33use VuFind\Date\DateException;
34use VuFind\Exception\ILS as ILSException;
35use VuFind\I18n\Translator\TranslatorAwareInterface;
36use VuFindHttp\HttpServiceAwareInterface;
37
38use function call_user_func_array;
39use function count;
40use function func_get_args;
41use function in_array;
42use function intval;
43use function is_array;
44use function is_callable;
45use function is_string;
46use function strlen;
47
48/**
49 * III Sierra REST API driver
50 *
51 * @category VuFind
52 * @package  ILS_Drivers
53 * @author   Ere Maijala <ere.maijala@helsinki.fi>
54 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
55 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
56 */
57class SierraRest extends AbstractBase implements
58    TranslatorAwareInterface,
59    HttpServiceAwareInterface,
60    LoggerAwareInterface,
61    \VuFind\I18n\HasSorterInterface
62{
63    use \VuFind\Cache\CacheTrait;
64    use \VuFind\Log\LoggerAwareTrait {
65        logError as error;
66    }
67    use \VuFindHttp\HttpServiceAwareTrait;
68    use \VuFind\I18n\Translator\TranslatorAwareTrait;
69    use \VuFind\I18n\HasSorterTrait;
70    use \VuFind\Service\Feature\RetryTrait;
71    use \VuFind\Config\Feature\ExplodeSettingTrait;
72
73    /**
74     * Fixed field number for location in holdings records
75     *
76     * @var string
77     */
78    public const HOLDINGS_LOCATION_FIELD = '40';
79
80    /**
81     * Sierra INN-Reach Database connection
82     *
83     * @var ?resource
84     */
85    protected $innReachDb = null;
86
87    /**
88     * Fixed field number for item code 2 (ICODE2) in item records
89     *
90     * @var string
91     */
92    public const ITEM_ICODE2_FIELD = '60';
93
94    /**
95     * Fixed field number for item type (I TYPE) in item records
96     *
97     * @var string
98     */
99    public const ITEM_ITYPE_FIELD = '61';
100
101    /**
102     * Fixed field number for item last checkin date (LCHKIN) in item records
103     *
104     * @var string
105     */
106    public const ITEM_CHECKIN_DATE_FIELD = '68';
107
108    /**
109     * Fixed field number for OPAC message (OPACMSG) in item records
110     *
111     * @var string
112     */
113    public const ITEM_OPAC_MESSAGE_FIELD = '108';
114
115    /**
116     * Driver configuration
117     *
118     * @var array
119     */
120    protected $config;
121
122    /**
123     * Date converter
124     *
125     * @var \VuFind\Date\Converter
126     */
127    protected $dateConverter;
128
129    /**
130     * Factory function for constructing the SessionContainer.
131     *
132     * @var callable
133     */
134    protected $sessionFactory;
135
136    /**
137     * Session cache
138     *
139     * @var \Laminas\Session\Container
140     */
141    protected $sessionCache;
142
143    /**
144     * Whether item holds are enabled
145     *
146     * @var bool
147     */
148    protected $itemHoldsEnabled;
149
150    /**
151     * Item codes (ICODE2 in Sierra) for which item level hold is not allowed
152     *
153     * @var array
154     */
155    protected $itemHoldExcludedItemCodes = [];
156
157    /**
158     * Item types (I TYPE in Sierra) for which item level hold is not allowed
159     *
160     * @var array
161     */
162    protected $itemHoldExcludedItemTypes = [];
163
164    /**
165     * Bib levels for which item level hold is allowed. If null, negation of
166     * titleHoldBibLevels is used instead.
167     *
168     * @var ?array
169     */
170    protected $itemHoldBibLevels = null;
171
172    /**
173     * Bib levels for which title level hold is allowed
174     *
175     * @var array
176     */
177    protected $titleHoldBibLevels = [];
178
179    /**
180     * Default pickup location
181     *
182     * @var string
183     */
184    protected $defaultPickUpLocation = '';
185
186    /**
187     * Item statuses that allow placing an hold
188     *
189     * @var array
190     */
191    protected $validHoldStatuses = [];
192
193    /**
194     * Title hold rules
195     *
196     * @var array
197     */
198    protected $titleHoldRules = [];
199
200    /**
201     * Item statuses that count when $titleHoldRules contains "item".
202     *
203     * @var array
204     */
205    protected $titleHoldValidHoldStatuses = [];
206
207    /**
208     * Item codes (ICODE2 in Sierra) that cause an item to be ignored when
209     * $titleHoldRules contains "item".
210     *
211     * @var array
212     */
213    protected $titleHoldExcludedItemCodes = [];
214
215    /**
216     * Item types (I TYPE in Sierra) that cause an item to be ignored when
217     * $titleHoldRules contains "item".
218     *
219     * @var array
220     */
221    protected $titleHoldExcludedItemTypes = [];
222
223    /**
224     * Mappings from item status codes to VuFind strings
225     *
226     * @var array
227     */
228    protected $itemStatusMappings = [
229        '!' => 'On Holdshelf',
230        't' => 'In Transit',
231        'o' => 'On Reference Desk',
232        'k' => 'In Repair',
233        'm' => 'Missing',
234        'n' => 'Long Overdue',
235        '$' => 'Lost--Library Applied',
236        'p' => '',
237        'z' => 'Claims Returned',
238        's' => 'On Search',
239        'd' => 'In Process',
240        '-' => 'On Shelf',
241        'Charged' => 'Charged',
242        'Ordered' => 'Ordered',
243    ];
244
245    /**
246     * Mappings from patron block codes to VuFind strings
247     */
248    protected $patronBlockMappings = [];
249
250    /**
251     * Mappings from fine types to VuFind strings
252     *
253     * @var array
254     */
255    protected $fineTypeMappings = [];
256
257    /**
258     * Status codes indicating that a hold is available for pickup
259     *
260     * @var array
261     */
262    protected $holdAvailableCodes = ['b', 'j', 'i'];
263
264    /**
265     * Status codes indicating that a hold is in transit
266     *
267     * @var array
268     */
269    protected $holdInTransitCodes = ['t'];
270
271    /**
272     * Available API version
273     *
274     * Functionality requiring a specific minimum version:
275     *
276     * v5:
277     *   - last pickup date for holds
278     * v5.1 (technically still v5 but added in a later revision):
279     *   - summary holdings information (especially for serials)
280     *
281     * Note that API version 3 is deprecated in Sierra 5.1 and will be removed later
282     * on (reported March 2020).
283     *
284     * @var int
285     */
286    protected $apiVersion = 6;
287
288    /**
289     * API base path
290     *
291     * This should correspond to $apiVersion above
292     *
293     * @var string
294     */
295    protected $apiBase = 'v6';
296
297    /**
298     * Statistic group to use e.g. when renewing loans or placing holds
299     *
300     * @var ?int
301     */
302    protected $statGroup = null;
303
304    /**
305     * Whether to sort items by enumchron. Default is true.
306     *
307     * @var array
308     */
309    protected $sortItemsByEnumChron;
310
311    /**
312     * Whether to allow canceling of available holds
313     *
314     * @var bool
315     */
316    protected $allowCancelingAvailableRequests = false;
317
318    /**
319     * Whether to check hold freezability up front. Not enabled by default since
320     * Sierra versions prior to 5.6 return holds slowly if canFreeze is requested.
321     *
322     * @var bool
323     */
324    protected $checkFreezability = false;
325
326    /**
327     * Number of retries in case an API request fails with a retryable error (see
328     * $retryableRequestExceptionPatterns below).
329     *
330     * @var int
331     */
332    protected $httpRetryCount = 2;
333
334    /**
335     * Exception message regexp patterns for request errors that can be retried
336     *
337     * @var array
338     */
339    protected $retryableRequestExceptionPatterns = [
340        // cURL adapter:
341        '/Error in cURL request: Empty reply from server/',
342        // Socket adapter:
343        '/A valid response status line was not found in the provided string/',
344    ];
345
346    /**
347     * Bib cache entry life time in seconds
348     *
349     * @var int
350     */
351    protected $bibCacheTTL = 300;
352
353    /**
354     * Item cache entry life time in seconds
355     *
356     * @var int
357     */
358    protected $itemCacheTTL = 300;
359
360    /**
361     * Life time in seconds for cached items of a bibliographic record
362     *
363     * It is recommended to keep this fairly short to ensure that any recent changes
364     * (such as placing a hold) are reflected correctly in holdings.
365     *
366     * @var int
367     */
368    protected $bibItemsCacheTTL = 2;
369
370    /**
371     * Default list of bib fields to request from Sierra. This list must include
372     * at least 'title' and 'publishYear' needed to compose holds list and fines
373     * list. The cached entry will be augmented with any additional fields as needed,
374     * within the cache life time (see $bibCacheTTL).
375     *
376     * @var array
377     */
378    protected $defaultBibFields = ['default'];
379
380    /**
381     * Default list of item fields to request from Sierra. This list must include at
382     * least the fields needed to compose holdings and determine holdability.
383     *
384     * @var array
385     */
386    protected $defaultItemFields = [
387        'default',
388        'fixedFields',
389        'varFields',
390    ];
391
392    /**
393     * Constructor
394     *
395     * @param \VuFind\Date\Converter $dateConverter  Date converter object
396     * @param callable               $sessionFactory Factory function returning
397     * SessionContainer object
398     */
399    public function __construct(
400        \VuFind\Date\Converter $dateConverter,
401        $sessionFactory
402    ) {
403        $this->dateConverter = $dateConverter;
404        $this->sessionFactory = $sessionFactory;
405    }
406
407    /**
408     * Set configuration.
409     *
410     * Set the configuration for the driver.
411     *
412     * @param array $config Configuration array (usually loaded from a VuFind .ini
413     * file whose name corresponds with the driver class name).
414     *
415     * @return void
416     */
417    public function setConfig($config)
418    {
419        $this->config = $config;
420    }
421
422    /**
423     * Initialize the driver.
424     *
425     * Validate configuration and perform all resource-intensive tasks needed to
426     * make the driver active.
427     *
428     * @throws ILSException
429     * @return void
430     */
431    public function init()
432    {
433        if (empty($this->config)) {
434            throw new ILSException('Configuration needs to be set.');
435        }
436
437        // Validate config
438        $required = ['host', 'client_key', 'client_secret'];
439        foreach ($required as $current) {
440            if (!isset($this->config['Catalog'][$current])) {
441                throw new ILSException("Missing Catalog/{$current} config setting.");
442            }
443        }
444
445        $holdCfg = $this->config['Holds'] ?? [];
446
447        $this->validHoldStatuses = $this->explodeSetting($holdCfg['valid_hold_statuses'] ?? '');
448        $this->itemHoldsEnabled = $holdCfg['enableItemHolds'] ?? true;
449        $this->itemHoldExcludedItemCodes
450            = $this->explodeSetting($holdCfg['item_hold_excluded_item_codes'] ?? '');
451        $this->itemHoldExcludedItemTypes
452            = $this->explodeSetting($holdCfg['item_hold_excluded_item_types'] ?? '');
453        $this->itemHoldBibLevels = isset($holdCfg['item_hold_bib_levels'])
454            ? $this->explodeSetting($holdCfg['item_hold_bib_levels'] ?? '')
455            : null;
456
457        $this->titleHoldValidHoldStatuses = $this->explodeSetting(
458            $holdCfg['title_hold_valid_hold_statuses']
459            ?? $holdCfg['valid_hold_statuses']
460            ?? ''
461        );
462        $this->titleHoldBibLevels = $this->explodeSetting($holdCfg['title_hold_bib_levels'] ?? '');
463        $this->titleHoldRules = $this->explodeSetting($holdCfg['title_hold_rules'] ?? '');
464        $this->titleHoldExcludedItemCodes
465            = $this->explodeSetting($holdCfg['title_hold_excluded_item_codes'] ?? '');
466        $this->titleHoldExcludedItemTypes
467            = $this->explodeSetting($holdCfg['title_hold_excluded_item_types'] ?? '');
468
469        $this->allowCancelingAvailableRequests
470            = $holdCfg['allowCancelingAvailableRequests'] ?? false;
471        $this->defaultPickUpLocation = $holdCfg['defaultPickUpLocation'] ?? '';
472        if ($this->defaultPickUpLocation === 'user-selected') {
473            $this->defaultPickUpLocation = false;
474        }
475        $this->checkFreezability = (bool)($holdCfg['checkFreezability'] ?? false);
476
477        if (!empty($this->config['ItemStatusMappings'])) {
478            $this->itemStatusMappings = array_merge(
479                $this->itemStatusMappings,
480                $this->config['ItemStatusMappings']
481            );
482        }
483        $this->patronBlockMappings = $this->config['PatronBlockMappings'] ?? [];
484        $this->fineTypeMappings = (array)($this->config['FineTypeMappings'] ?? []);
485
486        if (isset($this->config['Catalog']['api_version'])) {
487            $this->apiVersion = $this->config['Catalog']['api_version'];
488            $this->apiBase = 'v' . floor($this->apiVersion);
489        }
490        if ($statGroup = $this->config['Catalog']['statgroup'] ?? null) {
491            if ($this->apiVersion >= 6) {
492                $this->statGroup = (int)$statGroup;
493            } else {
494                $this->logWarning("Ignoring statgroup for API Version {$this->apiVersion}");
495            }
496        }
497
498        if (null !== ($retries = $this->config['Catalog']['http_retries'] ?? null)) {
499            $this->httpRetryCount = (int)$retries;
500        }
501
502        $this->sortItemsByEnumChron
503            = $this->config['Holdings']['sort_by_enum_chron'] ?? true;
504
505        // Init session cache for session-specific data
506        $namespace = md5(
507            $this->config['Catalog']['host'] . '|'
508            . $this->config['Catalog']['client_key']
509        );
510        $factory = $this->sessionFactory;
511        $this->sessionCache = $factory($namespace);
512    }
513
514    /**
515     * Establish INN-Reach database connection
516     *
517     * @return ?resource
518     */
519    protected function getInnReachDb()
520    {
521        if (null === $this->innReachDb) {
522            try {
523                $conn_string = $this->config['InnReach']['sierra_db'];
524                $connection = pg_connect($conn_string);
525                $this->innReachDb = $connection;
526            } catch (\Exception $e) {
527                $this->logWarning("INN-Reach: Could not connect to the Sierra database: {$e}");
528                $this->innReachDb = null;
529            }
530        }
531        return $this->innReachDb;
532    }
533
534    /**
535     * Get Status
536     *
537     * This is responsible for retrieving the status information of a certain
538     * record.
539     *
540     * @param string $id The record id to retrieve the holdings for
541     *
542     * @return array An associative array with the following keys:
543     * id, availability (boolean), status, location, reserve, callnumber.
544     */
545    public function getStatus($id)
546    {
547        return $this->getItemStatusesForBib($id, $this->config['Holdings']['check_holdings_in_results'] ?? true);
548    }
549
550    /**
551     * Get Statuses
552     *
553     * This is responsible for retrieving the status information for a
554     * collection of records.
555     *
556     * @param array $ids The array of record ids to retrieve the status for
557     *
558     * @return mixed     An array of getStatus() return values on success.
559     */
560    public function getStatuses($ids)
561    {
562        $items = [];
563        foreach ($ids as $id) {
564            $items[] = $this->getStatus($id);
565        }
566        return $items;
567    }
568
569    /**
570     * Get Holding
571     *
572     * This is responsible for retrieving the holding information of a certain
573     * record.
574     *
575     * @param string $id      The record id to retrieve the holdings for
576     * @param array  $patron  Patron data
577     * @param array  $options Extra options (not currently used)
578     *
579     * @return mixed     On success, an associative array with the following keys:
580     * id, availability (boolean), status, location, reserve, callnumber, duedate,
581     * number, barcode.
582     *
583     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
584     */
585    public function getHolding($id, array $patron = null, array $options = [])
586    {
587        return $this->getItemStatusesForBib($id, true, $patron);
588    }
589
590    /**
591     * Get Purchase History
592     *
593     * This is responsible for retrieving the acquisitions history data for the
594     * specific record (usually recently received issues of a serial).
595     *
596     * @param string $id The record id to retrieve the info for
597     *
598     * @return mixed     An array with the acquisitions data on success.
599     *
600     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
601     */
602    public function getPurchaseHistory($id)
603    {
604        return [];
605    }
606
607    /**
608     * Get New Items
609     *
610     * Retrieve the IDs of items recently added to the catalog.
611     *
612     * @param int $page    Page number of results to retrieve (counting starts at 1)
613     * @param int $limit   The size of each page of results to retrieve
614     * @param int $daysOld The maximum age of records to retrieve in days (max. 30)
615     * @param int $fundId  optional fund ID to use for limiting results (use a value
616     * returned by getFunds, or exclude for no limit); note that "fund" may be a
617     * misnomer - if funds are not an appropriate way to limit your new item
618     * results, you can return a different set of values from getFunds. The
619     * important thing is that this parameter supports an ID returned by getFunds,
620     * whatever that may mean.
621     *
622     * @return array       Associative array with 'count' and 'results' keys
623     *
624     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
625     */
626    public function getNewItems($page, $limit, $daysOld, $fundId = null)
627    {
628        return ['count' => 0, 'results' => []];
629    }
630
631    /**
632     * Find Reserves
633     *
634     * Obtain information on course reserves.
635     *
636     * @param string $course ID from getCourses (empty string to match all)
637     * @param string $inst   ID from getInstructors (empty string to match all)
638     * @param string $dept   ID from getDepartments (empty string to match all)
639     *
640     * @return mixed An array of associative arrays representing reserve items.
641     *
642     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
643     */
644    public function findReserves($course, $inst, $dept)
645    {
646        return [];
647    }
648
649    /**
650     * Patron Login
651     *
652     * This is responsible for authenticating a patron against the catalog.
653     *
654     * @param string $username The patron username
655     * @param string $password The patron password
656     *
657     * @return mixed           Associative array of patron info on successful login,
658     * null on unsuccessful login.
659     *
660     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
661     */
662    public function patronLogin($username, $password)
663    {
664        // If we are using a patron-specific access grant, we can bypass
665        // authentication as the credentials are verified when the access token is
666        // requested.
667        if ($this->isPatronSpecificAccess()) {
668            $patron = $this->getPatronInformationFromAuthToken($username, $password);
669            if (!$patron) {
670                return null;
671            }
672        } else {
673            $patron = $this->authenticatePatron($username, $password);
674            if (!$patron) {
675                return null;
676            }
677        }
678
679        $firstname = '';
680        $lastname = '';
681        if (!empty($patron['names'])) {
682            $name = $patron['names'][0];
683            $parts = explode(', ', $name, 2);
684            $lastname = $parts[0];
685            $firstname = $parts[1] ?? '';
686        }
687        return [
688            'id' => $patron['id'],
689            'firstname' => $firstname,
690            'lastname' => $lastname,
691            'cat_username' => $username,
692            'cat_password' => $password,
693            'email' => !empty($patron['emails']) ? $patron['emails'][0] : '',
694            'major' => null,
695            'college' => null,
696        ];
697    }
698
699    /**
700     * Check whether the patron is blocked from placing requests (holds/ILL/SRR).
701     *
702     * @param array $patron Patron data from patronLogin().
703     *
704     * @return mixed A boolean false if no blocks are in place and an array
705     * of block reasons if blocks are in place
706     */
707    public function getRequestBlocks($patron)
708    {
709        return $this->getPatronBlocks($patron);
710    }
711
712    /**
713     * Check whether the patron has any blocks on their account.
714     *
715     * @param array $patron Patron data from patronLogin().
716     *
717     * @return mixed A boolean false if no blocks are in place and an array
718     * of block reasons if blocks are in place
719     */
720    public function getAccountBlocks($patron)
721    {
722        return $this->getPatronBlocks($patron);
723    }
724
725    /**
726     * Get Patron Profile
727     *
728     * This is responsible for retrieving the profile for a specific patron.
729     *
730     * @param array $patron The patron array
731     *
732     * @throws ILSException
733     * @return array        Array of the patron's profile data on success.
734     */
735    public function getMyProfile($patron)
736    {
737        $result = $this->makeRequest(
738            [$this->apiBase, 'patrons', $patron['id']],
739            [
740                'fields' => 'default,names,emails,phones,addresses',
741            ],
742            'GET',
743            $patron
744        );
745
746        if (empty($result)) {
747            return [];
748        }
749        $firstname = '';
750        $lastname = '';
751        $address = '';
752        $zip = '';
753        $city = '';
754        if (!empty($result['names'])) {
755            $nameParts = explode(', ', $result['names'][0], 2);
756            $lastname = $nameParts[0];
757            $firstname = $nameParts[1] ?? '';
758        }
759        if (!empty($result['addresses'][0]['lines'][1])) {
760            $address = $result['addresses'][0]['lines'][0];
761            $postalParts = explode(' ', $result['addresses'][0]['lines'][1], 2);
762            if (isset($postalParts[1])) {
763                $zip = $postalParts[0];
764                $city = $postalParts[1];
765            } else {
766                $city = $postalParts[0];
767            }
768        }
769        $expirationDate = !empty($result['expirationDate'])
770                ? $this->dateConverter->convertToDisplayDate(
771                    'Y-m-d',
772                    $result['expirationDate']
773                ) : null;
774        return [
775            'firstname' => $firstname,
776            'lastname' => $lastname,
777            'phone' => !empty($result['phones'][0]['number'])
778                ? $result['phones'][0]['number'] : '',
779            'email' => !empty($result['emails']) ? $result['emails'][0] : '',
780            'address1' => $address,
781            'zip' => $zip,
782            'city' => $city,
783            'birthdate' => $result['birthDate'] ?? '',
784            'expiration_date' => $expirationDate,
785        ];
786    }
787
788    /**
789     * Get Patron Transactions
790     *
791     * This is responsible for retrieving all transactions (i.e. checked out items)
792     * by a specific patron.
793     *
794     * @param array $patron The patron array from patronLogin
795     * @param array $params Parameters
796     *
797     * @throws DateException
798     * @throws ILSException
799     * @return array        Array of the patron's transactions on success.
800     */
801    public function getMyTransactions($patron, $params = [])
802    {
803        $pageSize = $params['limit'] ?? 50;
804        $offset = isset($params['page']) ? ($params['page'] - 1) * $pageSize : 0;
805
806        $result = $this->makeRequest(
807            [$this->apiBase, 'patrons', $patron['id'], 'checkouts'],
808            [
809                'limit' => $pageSize,
810                'offset' => $offset,
811                'fields' => 'default,numberOfRenewals,callNumber,barcode',
812            ],
813            'GET',
814            $patron
815        );
816        if (empty($result['entries'])) {
817            return [
818                'count' => $result['total'],
819                'records' => [],
820            ];
821        }
822
823        $items = $this->getItemsWithBibsForTransactions($result['entries'], $patron);
824        $transactions = [];
825        foreach ($result['entries'] as $entry) {
826            $transaction = [
827                'id' => '',
828                'checkout_id' => $this->extractId($entry['id']),
829                'item_id' => $this->extractId($entry['item']),
830                'barcode' => $entry['barcode'],
831                'duedate' => $this->dateConverter->convertToDisplayDate(
832                    'Y-m-d',
833                    $entry['dueDate']
834                ),
835                'dueStatus' => $this->getDueStatus($entry),
836                'renew' => $entry['numberOfRenewals'],
837                'renewable' => true, // assumption, who knows?
838            ];
839            if (!empty($entry['recallDate'])) {
840                $date = $this->dateConverter->convertToDisplayDate(
841                    'Y-m-d',
842                    $entry['recallDate']
843                );
844                $transaction['message']
845                    = $this->translate('item_recalled', ['%%date%%' => $date]);
846            }
847            $item = $items[$transaction['item_id']] ?? null;
848            $transaction['volume'] = $item ? $this->extractVolume($item) : '';
849            if (!empty($item['bib'])) {
850                $bib = $item['bib'];
851                $transaction['id'] = $this->formatBibId($bib['id']);
852                if (!empty($bib['title'])) {
853                    $transaction['title'] = $bib['title'];
854                }
855                if (!empty($bib['publishYear'])) {
856                    $transaction['publication_year'] = $bib['publishYear'];
857                }
858            }
859            $transactions[] = $transaction;
860        }
861        if ($this->config['InnReach']['enabled'] ?? false) {
862            foreach ($transactions as $n => $transaction) {
863                $irIdentifier = $this->config['InnReach']['identifier'];
864                if ($transaction['item_id'] && strstr($transaction['item_id'], $irIdentifier)) {
865                    $irCheckoutId = $transaction['checkout_id'];
866                    $irItemId = $transaction['item_id'];
867                    $innReach = $this->getInnReachCheckoutTitleInfoFromId($irCheckoutId, $irItemId);
868
869                    if (!empty($innReach)) {
870                        $transactions[$n]['title'] = $innReach['title'];
871                        $transactions[$n]['author'] = $innReach['author'];
872                    }
873                }
874            }
875        }
876
877        return [
878            'count' => $result['total'],
879            'records' => $transactions,
880        ];
881    }
882
883    /**
884     * Get Renew Details
885     *
886     * @param array $checkOutDetails An array of item data
887     *
888     * @return string Data for use in a form field
889     */
890    public function getRenewDetails($checkOutDetails)
891    {
892        return $checkOutDetails['checkout_id'] . '|' . $checkOutDetails['item_id'];
893    }
894
895    /**
896     * Renew My Items
897     *
898     * Function for attempting to renew a patron's items. The data in
899     * $renewDetails['details'] is determined by getRenewDetails().
900     *
901     * @param array $renewDetails An array of data required for renewing items
902     * including the Patron ID and an array of renewal IDS
903     *
904     * @return array              An array of renewal information keyed by item ID
905     */
906    public function renewMyItems($renewDetails)
907    {
908        $patron = $renewDetails['patron'];
909        $finalResult = ['details' => []];
910
911        foreach ($renewDetails['details'] as $details) {
912            [$checkoutId, $itemId] = explode('|', $details);
913            $result = $this->makeRequest(
914                [$this->apiBase, 'patrons', 'checkouts', $checkoutId, 'renewal'],
915                [],
916                'POST',
917                $patron,
918                false,
919                $this->statGroup ? ['statgroup' => $this->statGroup] : []
920            );
921            if (!empty($result['code'])) {
922                $msg = $this->formatErrorMessage(
923                    $result['description'] ?? $result['name']
924                );
925                $finalResult['details'][$itemId] = [
926                    'item_id' => $itemId,
927                    'success' => false,
928                    'sysMessage' => $msg,
929                ];
930            } else {
931                $newDate = $this->dateConverter->convertToDisplayDate(
932                    'Y-m-d',
933                    $result['dueDate']
934                );
935                $finalResult['details'][$itemId] = [
936                    'item_id' => $itemId,
937                    'success' => true,
938                    'new_date' => $newDate,
939                ];
940            }
941        }
942        return $finalResult;
943    }
944
945    /**
946     * Get Patron Transaction History
947     *
948     * This is responsible for retrieving all historic transactions (i.e. checked
949     * out items) by a specific patron.
950     *
951     * @param array $patron The patron array from patronLogin
952     * @param array $params Parameters
953     *
954     * @throws DateException
955     * @throws ILSException
956     * @return array        Array of the patron's historic transactions on success.
957     */
958    public function getMyTransactionHistory($patron, $params)
959    {
960        $pageSize = $params['limit'] ?? 50;
961        $offset = isset($params['page']) ? ($params['page'] - 1) * $pageSize : 0;
962        $sortOrder = isset($params['sort']) && 'checkout asc' === $params['sort']
963            ? 'asc' : 'desc';
964        $result = $this->makeRequest(
965            [$this->apiBase, 'patrons', $patron['id'], 'checkouts', 'history'],
966            [
967                'limit' => $pageSize,
968                'offset' => $offset,
969                'sortField' => 'outDate',
970                'sortOrder' => $sortOrder,
971            ],
972            'GET',
973            $patron
974        );
975        if (!empty($result['code'])) {
976            return [
977                'success' => false,
978                'status' => 146 === $result['code']
979                    ? 'ils_transaction_history_disabled'
980                    : 'ils_connection_failed',
981            ];
982        }
983
984        $items = $this->getItemsWithBibsForTransactions($result['entries'], $patron);
985        $transactions = [];
986        foreach ($result['entries'] as $entry) {
987            $transaction = [
988                'id' => '',
989                'row_id' => $this->extractId($entry['id']),
990                'item_id' => $this->extractId($entry['item']),
991                'checkoutDate' => $this->dateConverter->convertToDisplayDate(
992                    'Y-m-d',
993                    $entry['outDate']
994                ),
995            ];
996            $item = $items[$transaction['item_id']] ?? null;
997            $transaction['volume'] = $item ? $this->extractVolume($item) : '';
998            if (!empty($item['bib'])) {
999                $bib = $item['bib'];
1000                $transaction['id'] = $this->formatBibId($bib['id']);
1001
1002                if (!empty($bib['title'])) {
1003                    $transaction['title'] = $bib['title'];
1004                }
1005                if (!empty($bib['publishYear'])) {
1006                    $transaction['publication_year'] = $bib['publishYear'];
1007                }
1008            }
1009            $transactions[] = $transaction;
1010        }
1011
1012        return [
1013            'count' => $result['total'] ?? 0,
1014            'transactions' => $transactions,
1015        ];
1016    }
1017
1018    /**
1019     * Purge Patron Transaction History
1020     *
1021     * @param array  $patron The patron array from patronLogin
1022     * @param ?array $ids    IDs to purge, or null for all
1023     *
1024     * @throws ILSException
1025     * @return array Associative array of the results
1026     */
1027    public function purgeTransactionHistory(array $patron, ?array $ids): array
1028    {
1029        if (null === $ids) {
1030            $result = $this->makeRequest(
1031                [
1032                    'v6', 'patrons', $patron['id'], 'checkouts', 'history',
1033                ],
1034                '',
1035                'DELETE',
1036                $patron
1037            );
1038            if (!empty($result['code'])) {
1039                return [
1040                    'success' => false,
1041                    'status' => $this->formatErrorMessage(
1042                        $result['description'] ?? $result['name']
1043                    ),
1044                ];
1045            }
1046        } else {
1047            foreach ($ids as $id) {
1048                $result = $this->makeRequest(
1049                    [
1050                        'v6', 'patrons', $patron['id'], 'checkouts', 'history', $id,
1051                    ],
1052                    '',
1053                    'DELETE',
1054                    $patron
1055                );
1056                if (!empty($result['code'])) {
1057                    return [
1058                        'success' => false,
1059                        'status' => $this->formatErrorMessage(
1060                            $result['description'] ?? $result['name']
1061                        ),
1062                    ];
1063                }
1064            }
1065        }
1066
1067        return [
1068            'success' => true,
1069            'status' => null === $ids
1070                ? 'loan_history_all_purged' : 'loan_history_selected_purged',
1071            'sysMessage' => '',
1072        ];
1073    }
1074
1075    /**
1076     * Get Patron Holds
1077     *
1078     * This is responsible for retrieving all holds by a specific patron.
1079     *
1080     * @param array $patron The patron array from patronLogin
1081     *
1082     * @throws DateException
1083     * @throws ILSException
1084     * @return array        Array of the patron's holds on success.
1085     * @todo   Support for handling frozen and pickup location change
1086     */
1087    public function getMyHolds($patron)
1088    {
1089        $fields = 'default,location,priorityQueueLength';
1090        if ($this->apiVersion >= 5) {
1091            $fields .= ',pickupByDate';
1092        }
1093        if ($this->apiVersion >= 6) {
1094            $fields .= ',notNeededAfterDate';
1095        }
1096        $freezeEnabled = in_array(
1097            'frozen',
1098            explode(':', $this->config['Holds']['updateFields'] ?? '')
1099        );
1100        if ($useCanFreeze = $freezeEnabled && $this->checkFreezability) {
1101            $fields .= ',canFreeze';
1102        }
1103
1104        $result = $this->makeRequest(
1105            [$this->apiBase, 'patrons', $patron['id'], 'holds'],
1106            [
1107                'limit' => 10000,
1108                'fields' => $fields,
1109            ],
1110            'GET',
1111            $patron
1112        );
1113        if (!isset($result['entries'])) {
1114            return [];
1115        }
1116        // Collect all item and bib records to fetch:
1117        $itemIds = [];
1118        $bibIds = [];
1119        foreach ($result['entries'] as $entry) {
1120            $recordId = $this->extractId($entry['record']);
1121            if ($entry['recordType'] === 'i') {
1122                $itemIds[] = $recordId;
1123            } elseif ($entry['recordType'] === 'b') {
1124                $bibIds[] = $recordId;
1125            }
1126        }
1127        // Fetch items in a batch and add any bib id's from them:
1128        $items = $this->getItemRecords($itemIds, null, $patron);
1129        foreach ($items as $item) {
1130            if (!empty($item['bibIds'])) {
1131                $bibIds[] = $item['bibIds'][0];
1132            }
1133        }
1134        // Fetch bibs in a batch:
1135        $bibs = $this->getBibRecords($bibIds, null, $patron);
1136
1137        $holds = [];
1138        foreach ($result['entries'] as $entry) {
1139            $bibId = null;
1140            $itemId = null;
1141            $title = '';
1142            $volume = '';
1143            $publicationYear = '';
1144            if ($entry['recordType'] == 'i') {
1145                $itemId = $this->extractId($entry['record']);
1146                // Fetch bib ID from item
1147                $item = $items[$itemId] ?? [];
1148                if (!empty($item['bibIds'])) {
1149                    $bibId = $item['bibIds'][0];
1150                }
1151                $volume = $this->extractVolume($item);
1152            } elseif ($entry['recordType'] == 'b') {
1153                $bibId = $this->extractId($entry['record']);
1154            }
1155            if (!empty($bibId)) {
1156                // Fetch bib information
1157                $bib = $bibs[$bibId] ?? [];
1158                $title = $bib['title'] ?? '';
1159                $publicationYear = $bib['publishYear'] ?? '';
1160            }
1161            $available = in_array($entry['status']['code'], $this->holdAvailableCodes);
1162            $inTransit = in_array($entry['status']['code'], $this->holdInTransitCodes);
1163            if ($entry['priority'] >= $entry['priorityQueueLength']) {
1164                // This can happen, no idea why
1165                $position = $entry['priorityQueueLength'] . ' / '
1166                    . $entry['priorityQueueLength'];
1167            } else {
1168                $position = $entry['priority'] . ' / '
1169                    . $entry['priorityQueueLength'];
1170            }
1171            $lastPickup = !empty($entry['pickupByDate'])
1172                ? $this->dateConverter->convertToDisplayDate(
1173                    'Y-m-d',
1174                    $entry['pickupByDate']
1175                ) : '';
1176            $requestId = $this->extractId($entry['id']);
1177            // Allow the user to attempt update if frozen status is togglable or the
1178            // hold is not available or in transit.
1179            // Checking if the hold can be frozen is optional since it's slow on
1180            // Sierra versions prior to 5.6.
1181            $frozenTogglable = $useCanFreeze
1182                ? !empty($entry['frozen']) || !empty($entry['canFreeze'])
1183                : $freezeEnabled;
1184            $updateDetails = ($frozenTogglable || (!$available && !$inTransit))
1185                ? $requestId : '';
1186            $cancelDetails = $this->allowCancelingAvailableRequests
1187                || (!$available && !$inTransit) ? $requestId : '';
1188            $holds[] = [
1189                'id' => $this->formatBibId($bibId),
1190                'reqnum' => $requestId,
1191                'item_id' => $itemId ? $itemId : $this->extractId($entry['id']),
1192                // note that $entry['pickupLocation']['name'] may contain misleading
1193                // text, so we instead use the code here:
1194                'location' => $entry['pickupLocation']['code'],
1195                'create' => $this->dateConverter->convertToDisplayDate(
1196                    'Y-m-d',
1197                    $entry['placed']
1198                ),
1199                'expire' => !empty($entry['notNeededAfterDate'])
1200                    ? $this->dateConverter->convertToDisplayDate(
1201                        'Y-m-d',
1202                        $entry['notNeededAfterDate']
1203                    ) : null,
1204                'last_pickup_date' => $lastPickup,
1205                'position' => $position,
1206                'available' => $available,
1207                'in_transit' => $inTransit,
1208                'volume' => $volume,
1209                'publication_year' => $publicationYear,
1210                'title' => $title,
1211                'frozen' => !empty($entry['frozen']),
1212                'cancel_details' => $cancelDetails,
1213                'updateDetails' => $updateDetails,
1214            ];
1215        }
1216
1217        if ($this->config['InnReach']['enabled'] ?? false) {
1218            foreach ($holds as $n => $hold) {
1219                if (!empty($hold['item_id']) && strstr($hold['item_id'], $this->config['InnReach']['identifier'])) {
1220                    $id = $hold['id'];
1221                    $volume = $hold['volume'];
1222
1223                    $innReach = $this->getInnReachHoldTitleInfoFromId($hold['reqnum'], $hold['id']);
1224                    if (!empty($innReach)) {
1225                        $holds[$n]['id'] = $innReach['id'];
1226                        $holds[$n]['title'] = $innReach['title'];
1227                        $holds[$n]['author'] = $innReach['author'];
1228                    }
1229                }
1230            }
1231        }
1232        return $holds;
1233    }
1234
1235    /**
1236     * Cancel Holds
1237     *
1238     * Attempts to Cancel a hold. The data in $cancelDetails['details'] is taken from
1239     * holds' cancel_details field.
1240     *
1241     * @param array $cancelDetails An array of item and patron data
1242     *
1243     * @return array               An array of data on each request including
1244     * whether or not it was successful and a system message (if available)
1245     */
1246    public function cancelHolds($cancelDetails)
1247    {
1248        $details = $cancelDetails['details'];
1249        $patron = $cancelDetails['patron'];
1250        $count = 0;
1251        $response = [];
1252
1253        foreach ($details as $holdId) {
1254            $result = $this->makeRequest(
1255                [$this->apiBase, 'patrons', 'holds', $holdId],
1256                '',
1257                'DELETE',
1258                $patron
1259            );
1260
1261            if (!empty($result['code'])) {
1262                $msg = $this->formatErrorMessage(
1263                    $result['description'] ?? $result['name']
1264                );
1265                $response[$holdId] = [
1266                    'item_id' => $holdId,
1267                    'success' => false,
1268                    'status' => 'hold_cancel_fail',
1269                    'sysMessage' => $msg,
1270                ];
1271            } else {
1272                $response[$holdId] = [
1273                    'item_id' => $holdId,
1274                    'success' => true,
1275                    'status' => 'hold_cancel_success',
1276                ];
1277                ++$count;
1278            }
1279        }
1280        return ['count' => $count, 'items' => $response];
1281    }
1282
1283    /**
1284     * Update holds
1285     *
1286     * This is responsible for changing the status of hold requests
1287     *
1288     * @param array $holdsDetails The details identifying the holds
1289     * @param array $fields       An associative array of fields to be updated
1290     * @param array $patron       Patron array
1291     *
1292     * @return array Associative array of the results
1293     */
1294    public function updateHolds(
1295        array $holdsDetails,
1296        array $fields,
1297        array $patron
1298    ): array {
1299        $results = [];
1300        foreach ($holdsDetails as $requestId) {
1301            // Fetch existing hold status:
1302            $reqFields = 'default' . (isset($fields['frozen']) ? ',canFreeze' : '');
1303            $hold = $this->makeRequest(
1304                [$this->apiBase, 'patrons', 'holds', $requestId],
1305                [
1306                    'fields' => $reqFields,
1307                ],
1308                'GET',
1309                $patron
1310            );
1311            $available
1312                = in_array($hold['status']['code'], $this->holdAvailableCodes);
1313            $inTransit
1314                = in_array($hold['status']['code'], $this->holdInTransitCodes);
1315
1316            // Check if we can do the requested changes:
1317            $updateFields = [];
1318            $fieldsSkipped = false;
1319            if (isset($fields['frozen']) && $hold['frozen'] !== $fields['frozen']) {
1320                if ($fields['frozen'] && !$hold['canFreeze']) {
1321                    $fieldsSkipped = true;
1322                } else {
1323                    $updateFields['freeze'] = $fields['frozen'];
1324                }
1325            }
1326            if (isset($fields['pickUpLocation'])) {
1327                if ($available || $inTransit) {
1328                    $fieldsSkipped = true;
1329                } else {
1330                    $updateFields['pickupLocation'] = $fields['pickUpLocation'];
1331                }
1332            }
1333
1334            if (!$updateFields) {
1335                $results[$requestId] = [
1336                    'success' => false,
1337                    'status' => 'hold_error_update_blocked_status',
1338                ];
1339            } else {
1340                $result = $this->makeRequest(
1341                    [$this->apiBase, 'patrons', 'holds', $requestId],
1342                    json_encode($updateFields),
1343                    'PUT',
1344                    $patron
1345                );
1346
1347                if (!empty($result['code'])) {
1348                    $results[$requestId] = [
1349                        'success' => false,
1350                        'status' => $this->formatErrorMessage(
1351                            $result['description'] ?? $result['name']
1352                        ),
1353                    ];
1354                } elseif ($fieldsSkipped) {
1355                    $results[$requestId] = [
1356                        'success' => false,
1357                        'status' => 'hold_error_update_blocked_status',
1358                    ];
1359                } else {
1360                    $results[$requestId] = [
1361                        'success' => true,
1362                    ];
1363                }
1364            }
1365        }
1366
1367        return $results;
1368    }
1369
1370    /**
1371     * Get Pick Up Locations
1372     *
1373     * This is responsible for gettting a list of valid library locations for
1374     * holds / recall retrieval
1375     *
1376     * @param array $patron      Patron information returned by the patronLogin
1377     * method.
1378     * @param array $holdDetails Optional array, only passed in when getting a list
1379     * in the context of placing or editing a hold. When placing a hold, it contains
1380     * most of the same values passed to placeHold, minus the patron data. When
1381     * editing a hold it contains all the hold information returned by getMyHolds.
1382     * May be used to limit the pickup options or may be ignored. The driver must
1383     * not add new options to the return array based on this data or other areas of
1384     * VuFind may behave incorrectly.
1385     *
1386     * @throws ILSException
1387     * @return array        An array of associative arrays with locationID and
1388     * locationDisplay keys
1389     *
1390     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1391     */
1392    public function getPickUpLocations($patron = false, $holdDetails = null)
1393    {
1394        if (!empty($this->config['pickUpLocations'])) {
1395            $locations = [];
1396            foreach ($this->config['pickUpLocations'] as $id => $location) {
1397                $locations[] = [
1398                    'locationID' => $id,
1399                    'locationDisplay' => $this->translateLocation(
1400                        ['code' => $id, 'name' => $location]
1401                    ),
1402                ];
1403            }
1404            return $locations;
1405        }
1406
1407        $result = $this->makeRequest(
1408            [$this->apiBase, 'branches', 'pickupLocations'],
1409            [
1410                'limit' => 10000,
1411                'offset' => 0,
1412                'language' => $this->getTranslatorLocale(),
1413            ],
1414            'GET',
1415            $patron
1416        );
1417        if (!empty($result['code'])) {
1418            // An error was returned
1419            $this->error(
1420                "Request for pickup locations returned error code: {$result['code']}"
1421                . ", HTTP status: {$result['httpStatus']}, name: {$result['name']}"
1422            );
1423            throw new ILSException('Problem with Sierra REST API.');
1424        }
1425        if (empty($result)) {
1426            return [];
1427        }
1428
1429        $locations = [];
1430        foreach ($result as $entry) {
1431            $locations[] = [
1432                'locationID' => $entry['code'],
1433                'locationDisplay' => $this->translateLocation(
1434                    ['code' => $entry['code'], 'name' => $entry['name']]
1435                ),
1436            ];
1437        }
1438
1439        usort($locations, [$this, 'pickupLocationSortFunction']);
1440        return $locations;
1441    }
1442
1443    /**
1444     * Get Default Pick Up Location
1445     *
1446     * Returns the default pick up location
1447     *
1448     * @param array $patron      Patron information returned by the patronLogin
1449     * method.
1450     * @param array $holdDetails Optional array, only passed in when getting a list
1451     * in the context of placing a hold; contains most of the same values passed to
1452     * placeHold, minus the patron data. May be used to limit the pickup options
1453     * or may be ignored.
1454     *
1455     * @return false|string      The default pickup location for the patron or false
1456     * if the user has to choose.
1457     *
1458     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1459     */
1460    public function getDefaultPickUpLocation($patron = false, $holdDetails = null)
1461    {
1462        return $this->defaultPickUpLocation;
1463    }
1464
1465    /**
1466     * Check if request is valid
1467     *
1468     * This is responsible for determining if an item is requestable
1469     *
1470     * @param string $id     The Bib ID
1471     * @param array  $data   An Array of item data
1472     * @param array  $patron An array of patron data
1473     *
1474     * @return bool True if request is valid, false if not
1475     */
1476    public function checkRequestIsValid($id, $data, $patron)
1477    {
1478        if ($this->getPatronBlocks($patron)) {
1479            return false;
1480        }
1481        $level = $data['level'] ?? 'copy';
1482        if ('title' === $level) {
1483            $fields = ['bibLevel'];
1484            if (in_array('order', $this->titleHoldRules)) {
1485                $fields[] = 'orders';
1486            }
1487            $bib = $this->getBibRecord($id, $fields, $patron);
1488            if (
1489                !isset($bib['bibLevel']['code'])
1490                || !in_array($bib['bibLevel']['code'], $this->titleHoldBibLevels)
1491            ) {
1492                return false;
1493            }
1494            if (!$this->checkTitleHoldRules($bib, $patron)) {
1495                return false;
1496            }
1497        }
1498        return true;
1499    }
1500
1501    /**
1502     * Place Hold
1503     *
1504     * Attempts to place a hold or recall on a particular item and returns
1505     * an array with result details or throws an exception on failure of support
1506     * classes
1507     *
1508     * @param array $holdDetails An array of item and patron data
1509     *
1510     * @throws ILSException
1511     * @return mixed An array of data on the request including
1512     * whether or not it was successful and a system message (if available)
1513     */
1514    public function placeHold($holdDetails)
1515    {
1516        $patron = $holdDetails['patron'];
1517        $level = isset($holdDetails['level']) && !empty($holdDetails['level'])
1518            ? $holdDetails['level'] : 'copy';
1519        $pickUpLocation = !empty($holdDetails['pickUpLocation'])
1520            ? $holdDetails['pickUpLocation'] : $this->defaultPickUpLocation;
1521        $itemId = $holdDetails['item_id'] ?? false;
1522        $comment = $holdDetails['comment'] ?? '';
1523        $bibId = $this->extractBibId($holdDetails['id']);
1524
1525        if ($level == 'copy' && empty($itemId)) {
1526            throw new ILSException("Hold level is 'copy', but item ID is empty");
1527        }
1528
1529        // Make sure pickup location is valid
1530        if (!$this->pickUpLocationIsValid($pickUpLocation, $patron, $holdDetails)) {
1531            return $this->holdError('hold_invalid_pickup', false);
1532        }
1533
1534        $request = [
1535            'recordType' => $level == 'copy' ? 'i' : 'b',
1536            'recordNumber' => (int)($level == 'copy' ? $itemId : $bibId),
1537            'pickupLocation' => $pickUpLocation,
1538        ];
1539        if (!empty($holdDetails['requiredByTS'])) {
1540            $request['neededBy'] = gmdate('Y-m-d', $holdDetails['requiredByTS']);
1541        }
1542        if ($comment) {
1543            $request['note'] = $comment;
1544        }
1545        if ($this->statGroup) {
1546            $request['statgroup'] = $this->statGroup;
1547        }
1548
1549        $result = $this->makeRequest(
1550            [$this->apiBase, 'patrons', $patron['id'], 'holds', 'requests'],
1551            json_encode($request),
1552            'POST',
1553            $patron
1554        );
1555
1556        if (!empty($result['code'])) {
1557            return $this->holdError($result['description'] ?? $result['name']);
1558        }
1559        return ['success' => true];
1560    }
1561
1562    /**
1563     * Get Patron Fines
1564     *
1565     * This is responsible for retrieving all fines by a specific patron.
1566     *
1567     * @param array $patron The patron array from patronLogin
1568     *
1569     * @throws DateException
1570     * @throws ILSException
1571     * @return array        Array of the patron's fines on success.
1572     */
1573    public function getMyFines($patron)
1574    {
1575        $result = $this->makeRequest(
1576            [$this->apiBase, 'patrons', $patron['id'], 'fines'],
1577            [
1578                'limit' => 10000,
1579            ],
1580            'GET',
1581            $patron
1582        );
1583
1584        if (!isset($result['entries'])) {
1585            return [];
1586        }
1587
1588        // Collect all item records to fetch:
1589        $itemIds = [];
1590        foreach ($result['entries'] as $entry) {
1591            if (!empty($entry['item'])) {
1592                $itemIds[] = $this->extractId($entry['item']);
1593            }
1594        }
1595        // Fetch items in a batch and list the bibs:
1596        $items = $this->getItemRecords($itemIds, null, $patron);
1597        $bibIds = [];
1598        foreach ($items as $item) {
1599            if (!empty($item['bibIds'])) {
1600                $bibIds[] = $item['bibIds'][0];
1601            }
1602        }
1603        // Fetch bibs in a batch:
1604        $bibs = $this->getBibRecords($bibIds, null, $patron);
1605
1606        $fines = [];
1607        foreach ($result['entries'] as $entry) {
1608            $amount = $entry['itemCharge'] + $entry['processingFee']
1609                + $entry['billingFee'];
1610            $balance = $amount - $entry['paidAmount'];
1611            $type = $entry['chargeType']['display'] ?? '';
1612            $bibId = null;
1613            $title = null;
1614            if (!empty($entry['item'])) {
1615                $itemId = $this->extractId($entry['item']);
1616                // Fetch bib ID from item
1617                $item = $items[$itemId] ?? [];
1618                if (!empty($item['bibIds'])) {
1619                    $bibId = $item['bibIds'][0];
1620                    // Fetch bib information
1621                    $bib = $bibs[$bibId] ?? [];
1622                    $title = $bib['title'] ?? '';
1623                }
1624            }
1625
1626            $fines[] = [
1627                'amount' => $amount * 100,
1628                'fine' => $this->fineTypeMappings[$type] ?? $type,
1629                'description' => $entry['description'] ?? '',
1630                'balance' => $balance * 100,
1631                'createdate' => $this->dateConverter->convertToDisplayDate(
1632                    'Y-m-d',
1633                    $entry['assessedDate']
1634                ),
1635                'checkout' => '',
1636                'id' => $this->formatBibId($bibId),
1637                'title' => $title,
1638            ];
1639        }
1640        return $fines;
1641    }
1642
1643    /**
1644     * Change Password
1645     *
1646     * Attempts to change patron password (PIN code)
1647     *
1648     * @param array $details An array of patron id and old and new password:
1649     *
1650     * 'patron'      The patron array from patronLogin
1651     * 'oldPassword' Old password
1652     * 'newPassword' New password
1653     *
1654     * @return array An array of data on the request including
1655     * whether or not it was successful and a system message (if available)
1656     */
1657    public function changePassword($details)
1658    {
1659        // Force new login
1660        $this->sessionCache->accessTokenPatron = '';
1661        $patron = $this->patronLogin(
1662            $details['patron']['cat_username'],
1663            $details['oldPassword']
1664        );
1665        if (null === $patron) {
1666            return [
1667                'success' => false, 'status' => 'authentication_error_invalid',
1668            ];
1669        }
1670
1671        $newPIN = preg_replace('/[^\d]/', '', trim($details['newPassword']));
1672        if (strlen($newPIN) != 4) {
1673            return [
1674                'success' => false, 'status' => 'password_error_invalid',
1675            ];
1676        }
1677
1678        $request = ['pin' => $newPIN];
1679
1680        $result = $this->makeRequest(
1681            [$this->apiBase, 'patrons', $patron['id']],
1682            json_encode($request),
1683            'PUT',
1684            $patron
1685        );
1686
1687        if (!empty($result['code'])) {
1688            return [
1689                'success' => false,
1690                'status' => $this->formatErrorMessage(
1691                    $result['description'] ?? $result['name']
1692                ),
1693            ];
1694        }
1695        return ['success' => true, 'status' => 'change_password_ok'];
1696    }
1697
1698    /**
1699     * Public Function which retrieves renew, hold and cancel settings from the
1700     * driver ini file.
1701     *
1702     * @param string $function The name of the feature to be checked
1703     * @param array  $params   Optional feature-specific parameters (array)
1704     *
1705     * @return array An array with key-value pairs.
1706     *
1707     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1708     */
1709    public function getConfig($function, $params = [])
1710    {
1711        if ('getMyTransactions' === $function) {
1712            return [
1713                'max_results' => 100,
1714            ];
1715        }
1716        if ('getMyTransactionHistory' === $function) {
1717            if (empty($this->config['TransactionHistory']['enabled'])) {
1718                return false;
1719            }
1720            return [
1721                'max_results' => 100,
1722                'sort' => [
1723                    'checkout desc' => 'sort_checkout_date_desc',
1724                    'checkout asc' => 'sort_checkout_date_asc',
1725                ],
1726                'default_sort' => 'checkout desc',
1727                'purge_all'  => $this->config['TransactionHistory']['purgeAll'] ?? true,
1728                'purge_selected'  => $this->config['TransactionHistory']['purgeSelected'] ?? true,
1729            ];
1730        }
1731        return $this->config[$function] ?? false;
1732    }
1733
1734    /**
1735     * Helper method to determine whether or not a certain method can be
1736     * called on this driver. Required method for any smart drivers.
1737     *
1738     * @param string $method The name of the called method.
1739     * @param array  $params Array of passed parameters
1740     *
1741     * @return bool True if the method can be called with the given parameters,
1742     * false otherwise.
1743     *
1744     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1745     */
1746    public function supportsMethod($method, $params)
1747    {
1748        // Changing password is only available if properly configured.
1749        if ($method == 'changePassword') {
1750            return isset($this->config['changePassword']);
1751        }
1752        // Loan history is only available if properly configured
1753        if ($method == 'getMyTransactionHistory') {
1754            return !empty($this->config['TransactionHistory']['enabled']);
1755        }
1756        if ($method == 'purgeTransactionHistory') {
1757            return !empty($this->config['TransactionHistory']['enabled'])
1758                && $this->apiVersion >= 6;
1759        }
1760        return is_callable([$this, $method]);
1761    }
1762
1763    /**
1764     * Extract an ID from a URL (last number)
1765     *
1766     * @param string $url URL containing the ID
1767     *
1768     * @return string ID
1769     */
1770    protected function extractId($url)
1771    {
1772        $parts = explode('/', $url);
1773        return end($parts);
1774    }
1775
1776    /**
1777     * Extract volume from item record's varFields
1778     *
1779     * @param array $item Item record from Sierra
1780     *
1781     * @return string
1782     */
1783    protected function extractVolume($item)
1784    {
1785        foreach ($item['varFields'] ?? [] as $varField) {
1786            if ($varField['fieldTag'] == 'v') {
1787                return trim($varField['subfields'][0]['content'] ?? '');
1788            }
1789        }
1790        return '';
1791    }
1792
1793    /**
1794     * Make Request
1795     *
1796     * Makes a request to the Sierra REST API
1797     *
1798     * @param array  $hierarchy    Array of values to embed in the URL path of the
1799     * request
1800     * @param array  $params       A keyed array of query data
1801     * @param string $method       The http request method to use (Default is GET)
1802     * @param array  $patron       Patron information, if available
1803     * @param bool   $returnStatus Whether to return HTTP status code and response
1804     * as a keyed array instead of just the response
1805     * @param array  $queryParams  Additional query params that are added to the URL
1806     * regardless of request type
1807     *
1808     * @throws ILSException
1809     * @return mixed JSON response decoded to an associative array, an array of HTTP
1810     * status code and JSON response when $returnStatus is true or null on
1811     * authentication error when using patron-specific access
1812     */
1813    protected function makeRequest(
1814        $hierarchy,
1815        $params = [],
1816        $method = 'GET',
1817        $patron = false,
1818        $returnStatus = false,
1819        $queryParams = []
1820    ) {
1821        // Status logging callback:
1822        $statusCallback = function (
1823            $attempt,
1824            $exception
1825        ) use (
1826            $hierarchy,
1827            $params,
1828            $method
1829        ): void {
1830            $apiUrl = $this->getApiUrlFromHierarchy($hierarchy);
1831            $status = $exception
1832                ? (' failed (' . $exception->getMessage() . ')')
1833                : ' succeeded';
1834            $msg = "$method request for '$apiUrl' with params "
1835                . $this->varDump($params)
1836                . "$status on attempt $attempt";
1837            $this->logWarning($msg);
1838        };
1839
1840        // Callback that checks for a retryable exception:
1841        $retryableCallback = function ($attempt, $exception) {
1842            // Get the original HTTP exception:
1843            if (!($previous = $exception->getPrevious())) {
1844                return false;
1845            }
1846            $msg = $previous->getMessage();
1847            foreach ($this->retryableRequestExceptionPatterns as $pattern) {
1848                if (preg_match($pattern, $msg)) {
1849                    return true;
1850                }
1851            }
1852            return false;
1853        };
1854
1855        $args = func_get_args();
1856        return $this->callWithRetry(
1857            function () use ($args) {
1858                return call_user_func_array([$this, 'requestCallback'], $args);
1859            },
1860            $statusCallback,
1861            [
1862                'retryCount' => $this->httpRetryCount,
1863                'retryableExceptionCallback' => $retryableCallback,
1864            ]
1865        );
1866    }
1867
1868    /**
1869     * Callback used by makeRequest
1870     *
1871     * @param array  $hierarchy    Array of values to embed in the URL path of the
1872     * request
1873     * @param array  $params       A keyed array of query data
1874     * @param string $method       The http request method to use (Default is GET)
1875     * @param array  $patron       Patron information, if available
1876     * @param bool   $returnStatus Whether to return HTTP status code and response
1877     * as a keyed array instead of just the response
1878     * @param array  $queryParams  Additional query params that are added to the URL
1879     * regardless of request type
1880     *
1881     * @throws ILSException
1882     * @return mixed JSON response decoded to an associative array, an array of HTTP
1883     * status code and JSON response when $returnStatus is true or null on
1884     * authentication error when using patron-specific access
1885     */
1886    protected function requestCallback(
1887        $hierarchy,
1888        $params = [],
1889        $method = 'GET',
1890        $patron = false,
1891        $returnStatus = false,
1892        $queryParams = []
1893    ) {
1894        // Clear current access token if it's not specific to the given patron
1895        if (
1896            $patron && $this->isPatronSpecificAccess()
1897            && $this->sessionCache->accessTokenPatron != $patron['cat_username']
1898        ) {
1899            $this->sessionCache->accessToken = null;
1900        }
1901
1902        // Renew authentication token as necessary
1903        if (null === $this->sessionCache->accessToken) {
1904            if (!$this->renewAccessToken($patron)) {
1905                return null;
1906            }
1907        }
1908
1909        // Set up the request
1910        $apiUrl = $this->getApiUrlFromHierarchy($hierarchy);
1911        // Add additional query parameters directly to the URL because they cannot be
1912        // added with setParameterGet for POST request:
1913        if ($queryParams) {
1914            $apiUrl .= '?' . http_build_query($queryParams);
1915        }
1916
1917        // Create proxy request
1918        $client = $this->createHttpClient($apiUrl);
1919
1920        // Add params
1921        if ($method == 'GET') {
1922            $client->setParameterGet($params);
1923        } else {
1924            if (is_string($params)) {
1925                $client->getRequest()->setContent($params);
1926            } else {
1927                $client->setParameterPost($params);
1928            }
1929        }
1930
1931        // Set authorization header
1932        $headers = $client->getRequest()->getHeaders();
1933        $headers->addHeaderLine(
1934            'Authorization',
1935            "Bearer {$this->sessionCache->accessToken}"
1936        );
1937        if (is_string($params)) {
1938            $headers->addHeaderLine('Content-Type', 'application/json');
1939        }
1940
1941        $locale = $this->getTranslatorLocale();
1942        if ($locale != 'en') {
1943            $locale .= ', en;q=0.8';
1944        }
1945        $headers->addHeaderLine('Accept-Language', $locale);
1946
1947        // Send request and retrieve response
1948        $startTime = microtime(true);
1949        try {
1950            $response = $client->setMethod($method)->send();
1951        } catch (\Exception $e) {
1952            $params = $method == 'GET'
1953                ? $client->getRequest()->getQuery()->toString()
1954                : $client->getRequest()->getPost()->toString();
1955            $this->error(
1956                "$method request for '$apiUrl' with params '$params' and contents '"
1957                . $client->getRequest()->getContent() . "' caused exception: "
1958                . $e->getMessage()
1959            );
1960            throw new ILSException('Problem with Sierra REST API.', 0, $e);
1961        }
1962        // If we get a 401, we need to renew the access token and try again
1963        if ($response->getStatusCode() == 401) {
1964            if (!$this->renewAccessToken($patron)) {
1965                return null;
1966            }
1967            $client->getRequest()->getHeaders()->addHeaderLine(
1968                'Authorization',
1969                "Bearer {$this->sessionCache->accessToken}"
1970            );
1971            $response = $client->send();
1972        }
1973        $result = $response->getBody();
1974
1975        $this->debug(
1976            '[' . round(microtime(true) - $startTime, 4) . 's]'
1977            . " $method request $apiUrl" . PHP_EOL . 'response: ' . PHP_EOL
1978            . $result
1979        );
1980
1981        // Handle errors as complete failures only if the API call didn't return
1982        // valid JSON that the caller can handle
1983        $decodedResult = json_decode($result, true);
1984        if (!$response->isSuccess() && null === $decodedResult) {
1985            $params = $method == 'GET'
1986                ? $client->getRequest()->getQuery()->toString()
1987                : $client->getRequest()->getPost()->toString();
1988            $this->error(
1989                "$method request for '$apiUrl' with params '$params' and contents '"
1990                . $client->getRequest()->getContent() . "' failed: "
1991                . $response->getStatusCode() . ': ' . $response->getReasonPhrase()
1992                . ', response content: ' . $response->getBody()
1993            );
1994            throw new ILSException('Problem with Sierra REST API.');
1995        }
1996
1997        return $returnStatus
1998            ? [
1999                'statusCode' => $response->getStatusCode(),
2000                'response' => $decodedResult,
2001            ] : $decodedResult;
2002    }
2003
2004    /**
2005     * Build an API URL from a hierarchy array
2006     *
2007     * @param array $hierarchy Hierarchy
2008     *
2009     * @return string
2010     */
2011    protected function getApiUrlFromHierarchy(array $hierarchy): string
2012    {
2013        $url = $this->config['Catalog']['host'];
2014        foreach ($hierarchy as $value) {
2015            $url .= '/' . urlencode($value);
2016        }
2017        return $url;
2018    }
2019
2020    /**
2021     * Renew the API access token and store it in the cache.
2022     * Throw an exception if there is an error.
2023     *
2024     * @param array $patron Patron information, if available
2025     *
2026     * @return bool True on success, false on patron login failure
2027     * @throws ILSException
2028     */
2029    protected function renewAccessToken($patron = false)
2030    {
2031        $patronCode = false;
2032        if ($patron && $this->isPatronSpecificAccess()) {
2033            if (!($patronCode = $this->getPatronAuthorizationCode($patron))) {
2034                return false;
2035            }
2036        }
2037
2038        // Set up the request
2039        $apiUrl = $this->config['Catalog']['host'] . '/token';
2040
2041        // Create proxy request
2042        $client = $this->createHttpClient($apiUrl);
2043
2044        // Set headers
2045        $headers = $client->getRequest()->getHeaders();
2046        $authorization = $this->config['Catalog']['client_key'] . ':' .
2047            $this->config['Catalog']['client_secret'];
2048        $headers->addHeaderLine(
2049            'Authorization',
2050            'Basic ' . base64_encode($authorization)
2051        );
2052        $params = [];
2053        if ($patronCode) {
2054            $params['grant_type'] = 'authorization_code';
2055            $params['code'] = $patronCode;
2056            $params['redirect_uri'] = $this->config['Catalog']['redirect_uri'];
2057        } else {
2058            $params['grant_type'] = 'client_credentials';
2059        }
2060        $client->setParameterPost($params);
2061
2062        // Send request and retrieve response
2063        $startTime = microtime(true);
2064        try {
2065            $response = $client->setMethod('POST')->send();
2066        } catch (\Exception $e) {
2067            $this->error(
2068                "POST request for '$apiUrl' caused exception: "
2069                . $e->getMessage()
2070            );
2071            throw new ILSException('Problem with Sierra REST API.', 0, $e);
2072        }
2073
2074        if (!$response->isSuccess()) {
2075            $this->error(
2076                "POST request for '$apiUrl' with contents '"
2077                . $client->getRequest()->getContent() . "' failed: "
2078                . $response->getStatusCode() . ': ' . $response->getReasonPhrase()
2079                . ', response content: ' . $response->getBody()
2080            );
2081            throw new ILSException('Problem with Sierra REST API.');
2082        }
2083        $result = $response->getBody();
2084
2085        $this->debug(
2086            '[' . round(microtime(true) - $startTime, 4) . 's]'
2087            . " GET request $apiUrl" . PHP_EOL . 'response: ' . PHP_EOL
2088            . $result
2089        );
2090
2091        $json = json_decode($result, true);
2092        $this->sessionCache->accessToken = $json['access_token'];
2093        $this->sessionCache->accessTokenPatron = $patronCode
2094            ? $patron['cat_username'] : null;
2095        return true;
2096    }
2097
2098    /**
2099     * Login and retrieve authorization code for the patron
2100     *
2101     * @param array $patron Patron information
2102     *
2103     * @return string|bool
2104     * @throws ILSException
2105     */
2106    protected function getPatronAuthorizationCode($patron)
2107    {
2108        // Do a patron login and then perform an authorization grant request
2109        $redirectUri = $this->config['Catalog']['redirect_uri'];
2110        $params = [
2111            'client_id' => $this->config['Catalog']['client_key'],
2112            'redirect_uri' => $redirectUri,
2113            'state' => 'auth',
2114            'response_type' => 'code',
2115        ];
2116        $apiUrl = $this->config['Catalog']['host'] . '/authorize'
2117            . '?' . http_build_query($params);
2118
2119        // First request the login form to get the hidden fields and cookies
2120        $client = $this->createHttpClient($apiUrl);
2121        try {
2122            $response = $client->send();
2123        } catch (\Exception $e) {
2124            $this->error(
2125                "GET request for '$apiUrl' caused exception: "
2126                . $e->getMessage()
2127            );
2128            throw new ILSException('Problem with Sierra REST API.', 0, $e);
2129        }
2130
2131        $doc = new \DOMDocument();
2132        if (!@$doc->loadHTML($response->getBody())) {
2133            $this->error('Could not parse the III CAS login form');
2134            throw new ILSException('Problem with Sierra login.');
2135        }
2136        $usernameField = $this->config['Authentication']['username_field'] ?? 'code';
2137        $passwordField = $this->config['Authentication']['password_field'] ?? 'pin';
2138        $postParams = [
2139            $usernameField => $patron['cat_username'],
2140            $passwordField => $patron['cat_password'],
2141        ];
2142        foreach ($doc->getElementsByTagName('input') as $input) {
2143            if ($input->getAttribute('type') == 'hidden') {
2144                $postParams[$input->getAttribute('name')]
2145                    = $input->getAttribute('value');
2146            }
2147        }
2148        $postUrl = $client->getUri();
2149        if ($form = $doc->getElementById('fm1')) {
2150            if ($action = $form->getAttribute('action')) {
2151                $actionUrl = new \Laminas\Uri\Http($action);
2152                if ($actionUrl->getScheme()) {
2153                    $postUrl = $actionUrl;
2154                } else {
2155                    $postUrl->setPath($actionUrl->getPath());
2156                    $postUrl->setQuery($actionUrl->getQuery());
2157                }
2158            }
2159        }
2160
2161        // Collect cookies for session etc.
2162        $cookies = $client->getCookies();
2163
2164        // Reset client
2165        $client->reset();
2166        $client->addCookie($cookies);
2167
2168        // Disable automatic following of redirects
2169        $client->setOptions(['maxredirects' => 0]);
2170        $adapter = $client->getAdapter();
2171        if ($adapter instanceof \Laminas\Http\Client\Adapter\Curl) {
2172            $adapter->setCurlOption(CURLOPT_FOLLOWLOCATION, false);
2173        }
2174
2175        // Send the login request
2176        $client->setParameterPost($postParams);
2177        $response = $client->setMethod('POST')->send();
2178        if (!$response->isSuccess() && !$response->isRedirect()) {
2179            $this->error(
2180                "POST request for '" . $client->getRequest()->getUriString()
2181                . "' did not return 302 redirect: "
2182                . $response->getStatusCode() . ': '
2183                . $response->getReasonPhrase()
2184                . ', response content: ' . $response->getBody()
2185            );
2186            throw new ILSException('Problem with Sierra login.');
2187        }
2188
2189        // Process redirects here until the configured redirect url is reached or
2190        // the sanity check for redirect count fails.
2191        $patronCode = false;
2192        $redirectCount = 0;
2193        while ($response->isRedirect() && ++$redirectCount < 10) {
2194            $location = $response->getHeaders()->get('Location')->getUri();
2195            if (strncmp($location, $redirectUri, strlen($redirectUri)) === 0) {
2196                // Don't try to parse the URI since Sierra creates it wrong if
2197                // the redirect_uri sent to it already contains a question mark.
2198                if (!preg_match('/code=([^&\?]+)/', $location, $matches)) {
2199                    $this->error(
2200                        "Could not parse authentication code from '$location'"
2201                    );
2202                    throw new ILSException('Problem with Sierra login.');
2203                }
2204                $patronCode = $matches[1];
2205                break;
2206            }
2207            $cookies = array_merge($cookies, $client->getCookies());
2208            $client->reset();
2209            $client->addCookie($cookies);
2210            $client->setUri($location);
2211            $client->setMethod('GET');
2212            $response = $client->send();
2213        }
2214
2215        return $patronCode;
2216    }
2217
2218    /**
2219     * Create a HTTP client
2220     *
2221     * @param string $url Request URL
2222     *
2223     * @return \Laminas\Http\Client
2224     */
2225    protected function createHttpClient($url)
2226    {
2227        $client = $this->httpService->createClient($url);
2228
2229        // Set timeout value
2230        $timeout = $this->config['Catalog']['http_timeout'] ?? 30;
2231        // Make sure keepalive is disabled as this is known to cause problems:
2232        $client->setOptions(
2233            ['timeout' => $timeout, 'useragent' => 'VuFind', 'keepalive' => false]
2234        );
2235
2236        // Set Accept header
2237        $client->getRequest()->getHeaders()->addHeaderLine(
2238            'Accept',
2239            'application/json'
2240        );
2241
2242        return $client;
2243    }
2244
2245    /**
2246     * Add instance-specific context to a cache key suffix to ensure that
2247     * multiple drivers don't accidentally share values in the cache.
2248     *
2249     * @param string $key Cache key suffix
2250     *
2251     * @return string
2252     */
2253    protected function formatCacheKey($key)
2254    {
2255        return 'SierraRest-' . md5($this->config['Catalog']['host'] . "|$key");
2256    }
2257
2258    /**
2259     * Extract a bib call number from a bib record (if configured to do so).
2260     *
2261     * @param array $bib Bib record
2262     *
2263     * @return string
2264     */
2265    protected function getBibCallNumber($bib)
2266    {
2267        $result = empty($this->config['CallNumber']['bib_fields'])
2268            ? '' : $this->extractFieldsFromApiData(
2269                [$bib], // wrap $bib in array to conform to expected format
2270                $this->config['CallNumber']['bib_fields']
2271            );
2272        return is_array($result) ? reset($result) : $result;
2273    }
2274
2275    /**
2276     * Get due status for a checkout
2277     *
2278     * @param array $checkout Checkout
2279     *
2280     * @return string
2281     */
2282    protected function getDueStatus(array $checkout): string
2283    {
2284        try {
2285            $dueDateTime = $this->dateConverter
2286                ->convertToDateTime('Y-m-d', $checkout['dueDate']);
2287            $dueDateTime->setTime(23, 59, 59, 999);
2288            $now = new \DateTime();
2289            if ($now > $dueDateTime) {
2290                return 'overdue';
2291            }
2292            if ($dueDateTime->diff($now)->days < 1) {
2293                return 'due';
2294            }
2295        } catch (\VuFind\Date\DateException $e) {
2296            // Due date not parseable, do nothing...
2297        }
2298        return '';
2299    }
2300
2301    /**
2302     * Get Item Statuses
2303     *
2304     * This is responsible for retrieving the status information of a certain
2305     * record.
2306     *
2307     * @param string $id            The record id to retrieve the holdings for
2308     * @param bool   $checkHoldings Whether to check holdings records
2309     * @param ?array $patron        Patron information, if available
2310     *
2311     * @return array An associative array with the following keys:
2312     * id, availability (boolean), status, location, reserve, callnumber.
2313     */
2314    protected function getItemStatusesForBib(string $id, bool $checkHoldings, ?array $patron = null): array
2315    {
2316        $bibFields = ['bibLevel'];
2317        // If we need to look at bib call numbers, retrieve varFields:
2318        if (!empty($this->config['CallNumber']['bib_fields'])) {
2319            $bibFields[] = 'varFields';
2320        }
2321        // Retrieve orders if needed:
2322        if (!empty($this->config['Holdings']['display_orders'])) {
2323            $bibFields[] = 'orders';
2324        }
2325        $bib = $this->getBibRecord($id, $bibFields, $patron);
2326        $bibCallNumber = $this->getBibCallNumber($bib);
2327        $orders = [];
2328        foreach ($bib['orders'] ?? [] as $order) {
2329            $location = $order['location']['code'];
2330            $orders[$location][] = $order;
2331        }
2332        $holdingsData = [];
2333        if ($checkHoldings && $this->apiVersion >= 5.1) {
2334            $holdingsResult = $this->makeRequest(
2335                [$this->apiBase, 'holdings'],
2336                [
2337                    'bibIds' => $this->extractBibId($id),
2338                    'deleted' => 'false',
2339                    'suppressed' => 'false',
2340                    'fields' => 'fixedFields,varFields',
2341                ],
2342                'GET'
2343            );
2344            foreach ($holdingsResult['entries'] ?? [] as $entry) {
2345                $location = '';
2346                foreach ($entry['fixedFields'] as $code => $field) {
2347                    if (
2348                        (string)$code === static::HOLDINGS_LOCATION_FIELD
2349                        || $field['label'] === 'LOCATION'
2350                    ) {
2351                        $location = $field['value'];
2352                        break;
2353                    }
2354                }
2355                if ('' === $location) {
2356                    continue;
2357                }
2358                $holdingsData[$location][] = $entry;
2359            }
2360        }
2361
2362        $items = $this->getItemsForBibRecord($id, null, $patron);
2363        $statuses = [];
2364        $sort = 0;
2365        foreach ($items as $item) {
2366            $location = $this->translateLocation($item['location']);
2367            [$status, $duedate, $notes] = $this->getItemStatus($item);
2368            $available = $status == $this->mapStatusCode('-');
2369            // OPAC message
2370            if (isset($item['fixedFields'][static::ITEM_OPAC_MESSAGE_FIELD])) {
2371                $opacMsg = $item['fixedFields'][static::ITEM_OPAC_MESSAGE_FIELD];
2372                $trimmedMsg = trim($opacMsg['value']);
2373                if (strlen($trimmedMsg) && $trimmedMsg != '-') {
2374                    $notes[] = $this->translateOpacMessage(
2375                        trim($opacMsg['value'])
2376                    );
2377                }
2378            }
2379            $callNumber = isset($item['callNumber'])
2380                ? $this->extractCallNumber($item['callNumber'])
2381                : $bibCallNumber;
2382            $volume = isset($item['varFields']) ? $this->extractVolume($item) : '';
2383
2384            $entry = [
2385                'id' => $id,
2386                'item_id' => $item['id'],
2387                'location' => $location,
2388                'availability' => $available,
2389                'status' => $status,
2390                'reserve' => 'N',
2391                'callnumber' => trim($callNumber),
2392                'duedate' => $duedate,
2393                'number' => trim($volume),
2394                'barcode' => $item['barcode'] ?? '',
2395                'sort' => $sort--,
2396            ];
2397            if ($notes) {
2398                $entry['item_notes'] = $notes;
2399            }
2400
2401            if ($this->isHoldable($item, $bib)) {
2402                $entry['is_holdable'] = true;
2403                $entry['level'] = 'copy';
2404                $entry['addLink'] = true;
2405            } else {
2406                $entry['is_holdable'] = false;
2407            }
2408
2409            $locationCode = $item['location']['code'] ?? '';
2410            if (!empty($holdingsData[$locationCode])) {
2411                $entry += $this->getHoldingsData($holdingsData[$locationCode]);
2412                $holdingsData[$locationCode]['_hasItems'] = true;
2413            }
2414
2415            $statuses[] = $entry;
2416        }
2417
2418        // Add holdings that don't have items
2419        foreach ($holdingsData as $locationCode => $holdings) {
2420            if (!empty($holdings['_hasItems'])) {
2421                continue;
2422            }
2423
2424            $location = $this->translateLocation(
2425                ['code' => $locationCode, 'name' => '']
2426            );
2427            $code = $locationCode;
2428            while ('' === $location && $code) {
2429                $location = $this->getLocationName($code);
2430                $code = substr($code, 0, -1);
2431            }
2432            $entry = [
2433                'id' => $id,
2434                'item_id' => 'HLD_' . $holdings[0]['id'],
2435                'location' => $location,
2436                'callnumber' => '',
2437                'requests_placed' => 0,
2438                'number' => '',
2439                'status' => '',
2440                'use_unknown_message' => true,
2441                'reserve' => 'N',
2442                'availability' => false,
2443                'duedate' => '',
2444                'barcode' => '',
2445                'sort' => $sort--,
2446            ];
2447            $entry += $this->getHoldingsData($holdings);
2448
2449            $statuses[] = $entry;
2450        }
2451
2452        // Add orders
2453        foreach ($orders as $locationCode => $orderSet) {
2454            $location = $this->translateLocation($orderSet[0]['location']);
2455            $statuses[] = [
2456                'id' => $id,
2457                'item_id' => "ORDER_{$id}_$locationCode",
2458                'location' => $location,
2459                'callnumber' => trim($bibCallNumber),
2460                'number' => '',
2461                'status' => $this->mapStatusCode('Ordered'),
2462                'reserve' => 'N',
2463                'item_notes' => $this->getOrderMessages($orderSet),
2464                'availability' => false,
2465                'duedate' => '',
2466                'barcode' => '',
2467                'sort' => $sort--,
2468            ];
2469        }
2470
2471        usort($statuses, [$this, 'statusSortFunction']);
2472        return $statuses;
2473    }
2474
2475    /**
2476     * Extract the actual call number from item's call number field
2477     *
2478     * @param string $callNumber Call number field
2479     *
2480     * @return string
2481     */
2482    protected function extractCallNumber(string $callNumber): string
2483    {
2484        return str_starts_with($callNumber, '|a') ? substr($callNumber, 2) : $callNumber;
2485    }
2486
2487    /**
2488     * Get textual messages for orders
2489     *
2490     * @param array $orders Orders
2491     *
2492     * @return array
2493     */
2494    protected function getOrderMessages(array $orders): array
2495    {
2496        $messages = [];
2497        foreach ($orders as $order) {
2498            $messages[] = $this->translate(
2499                [
2500                    'HoldingStatus',
2501                    1 === $order['copies']
2502                        ? 'copy_ordered_on_date'
2503                        : 'copies_ordered_on_date',
2504                ],
2505                [
2506                    '%%copies%%' => $order['copies'],
2507                    '%%date%%' => $this->dateConverter->convertToDisplayDate(
2508                        'Y-m-d',
2509                        $order['date']
2510                    ),
2511                ]
2512            );
2513        }
2514        return $messages;
2515    }
2516
2517    /**
2518     * Get holdings fields according to configuration
2519     *
2520     * @param array $holdings Holdings records
2521     *
2522     * @return array
2523     */
2524    protected function getHoldingsData($holdings)
2525    {
2526        $result = [];
2527        // Get Notes
2528        if (isset($this->config['Holdings']['notes'])) {
2529            $data = $this->extractFieldsFromApiData(
2530                $holdings,
2531                $this->config['Holdings']['notes']
2532            );
2533            if ($data) {
2534                $result['notes'] = $data;
2535            }
2536        }
2537
2538        // Get Summary (may be multiple lines)
2539        $data = $this->extractFieldsFromApiData(
2540            $holdings,
2541            $this->config['Holdings']['summary'] ?? 'h'
2542        );
2543        if ($data) {
2544            $result['summary'] = $data;
2545        }
2546
2547        // Get Supplements
2548        if (isset($this->config['Holdings']['supplements'])) {
2549            $data = $this->extractFieldsFromApiData(
2550                $holdings,
2551                $this->config['Holdings']['supplements']
2552            );
2553            if ($data) {
2554                $result['supplements'] = $data;
2555            }
2556        }
2557
2558        // Get Indexes
2559        if (isset($this->config['Holdings']['indexes'])) {
2560            $data = $this->extractFieldsFromApiData(
2561                $holdings,
2562                $this->config['Holdings']['indexes']
2563            );
2564            if ($data) {
2565                $result['indexes'] = $data;
2566            }
2567        }
2568        return $result;
2569    }
2570
2571    /**
2572     * Get fields from holdings or bib API response according to the field spec.
2573     *
2574     * @param array        $response   API response data
2575     * @param array|string $fieldSpecs Array or colon-separated list of
2576     * field/subfield specifications (3 chars for field code and then subfields,
2577     * e.g. 866az)
2578     *
2579     * @return string|string[] Results as a string if single, array if multiple
2580     */
2581    protected function extractFieldsFromApiData($response, $fieldSpecs)
2582    {
2583        if (!is_array($fieldSpecs)) {
2584            $fieldSpecs = explode(':', $fieldSpecs);
2585        }
2586        $result = [];
2587        foreach ($response as $row) {
2588            foreach ($fieldSpecs as $fieldSpec) {
2589                $fieldCode = substr($fieldSpec, 0, 3);
2590                $subfieldCodes = substr($fieldSpec, 3);
2591                $fields = $row['varFields'] ?? [];
2592                foreach ($fields as $field) {
2593                    if (
2594                        ($field['marcTag'] ?? '') !== $fieldCode
2595                        && ($field['fieldTag'] ?? '') !== $fieldCode
2596                    ) {
2597                        continue;
2598                    }
2599                    $subfields = $field['subfields'] ?? [
2600                        [
2601                            'tag' => '',
2602                            'content' => $field['content'] ?? '',
2603                        ],
2604                    ];
2605                    $line = [];
2606                    foreach ($subfields as $subfield) {
2607                        if (
2608                            $subfieldCodes
2609                            && !str_contains(
2610                                $subfieldCodes,
2611                                (string)$subfield['tag']
2612                            )
2613                        ) {
2614                            continue;
2615                        }
2616                        $line[] = $subfield['content'];
2617                    }
2618                    if ($line) {
2619                        $result[] = implode(' ', $line);
2620                    }
2621                }
2622            }
2623        }
2624        if (!$result) {
2625            return '';
2626        }
2627        return isset($result[1]) ? $result : $result[0];
2628    }
2629
2630    /**
2631     * Get name for a location code
2632     *
2633     * @param string $locationCode Location code
2634     *
2635     * @return string
2636     */
2637    protected function getLocationName($locationCode)
2638    {
2639        $locations = $this->getCachedData('locations');
2640        if (null === $locations) {
2641            $locations = [];
2642            $result = $this->makeRequest(
2643                [$this->apiBase, 'branches'],
2644                [
2645                    'limit' => 10000,
2646                ],
2647                'GET'
2648            );
2649            if (!empty($result['code'])) {
2650                // An error was returned
2651                $this->error(
2652                    "Request for branches returned error code: {$result['code']}"
2653                    . "HTTP status: {$result['httpStatus']}, name: {$result['name']}"
2654                );
2655                throw new ILSException('Problem with Sierra REST API.');
2656            }
2657            foreach (($result['entries'] ?? []) as $branch) {
2658                foreach (($branch['locations'] ?? []) as $location) {
2659                    $locations[$location['code']] = $this->translateLocation(
2660                        $location
2661                    );
2662                }
2663            }
2664            $this->putCachedData('locations', $locations);
2665        }
2666        return $locations[$locationCode] ?? '';
2667    }
2668
2669    /**
2670     * Translate location name
2671     *
2672     * @param array $location Location
2673     *
2674     * @return string
2675     */
2676    protected function translateLocation($location)
2677    {
2678        $prefix = 'location_';
2679        if (!empty($this->config['Catalog']['id'])) {
2680            $prefix .= $this->config['Catalog']['id'] . '_';
2681        }
2682        return $this->translate(
2683            $prefix . trim($location['code']),
2684            null,
2685            $location['name']
2686        );
2687    }
2688
2689    /**
2690     * Status item sort function
2691     *
2692     * @param array $a First status record to compare
2693     * @param array $b Second status record to compare
2694     *
2695     * @return int
2696     */
2697    protected function statusSortFunction($a, $b)
2698    {
2699        $result = $this->getSorter()->compare($a['location'], $b['location']);
2700        if ($result === 0 && $this->sortItemsByEnumChron) {
2701            $result = strnatcmp($b['number'] ?? '', $a['number'] ?? '');
2702        }
2703        if ($result === 0) {
2704            $result = $a['sort'] - $b['sort'];
2705        }
2706        return $result;
2707    }
2708
2709    /**
2710     * Translate OPAC message
2711     *
2712     * @param string $code OPAC message code
2713     *
2714     * @return string
2715     */
2716    protected function translateOpacMessage($code)
2717    {
2718        $prefix = 'opacmsg_';
2719        if (!empty($this->config['Catalog']['id'])) {
2720            $prefix .= $this->config['Catalog']['id'] . '_';
2721        }
2722        return $this->translate("$prefix$code", null, $code);
2723    }
2724
2725    /**
2726     * Get the human-readable equivalent of a status code.
2727     *
2728     * @param string $code    Code to map
2729     * @param string $default Default value if no mapping found
2730     *
2731     * @return string
2732     */
2733    protected function mapStatusCode($code, $default = null)
2734    {
2735        return trim($this->itemStatusMappings[$code] ?? $default ?? $code);
2736    }
2737
2738    /**
2739     * Get status for an item
2740     *
2741     * @param array $item Item from Sierra
2742     *
2743     * @return array Status string, possible due date and any notes
2744     */
2745    protected function getItemStatus($item)
2746    {
2747        $duedate = '';
2748        $notes = [];
2749        $status = $this->mapStatusCode(
2750            trim($item['status']['code']),
2751            isset($item['status']['display'])
2752                ? ucwords(strtolower($item['status']['display']))
2753                : '-'
2754        );
2755        // For some reason at least API v2.0 returns "ON SHELF" even when the
2756        // item is out. Use duedate to check if it's actually checked out.
2757        if (isset($item['status']['duedate'])) {
2758            $duedate = $this->dateConverter->convertToDisplayDate(
2759                \DateTime::ATOM,
2760                $item['status']['duedate']
2761            );
2762            $status = $this->mapStatusCode('Charged');
2763        } else {
2764            switch ($status) {
2765                case '-':
2766                    $status = $this->mapStatusCode('-');
2767                    break;
2768                case 'Lib Use Only':
2769                    $status = $this->mapStatusCode('o');
2770                    break;
2771            }
2772        }
2773        if ($status == $this->mapStatusCode('-')) {
2774            // Check for checkin date
2775            $today = $this->dateConverter->convertToDisplayDate('U', time());
2776            if (isset($item['fixedFields'][static::ITEM_CHECKIN_DATE_FIELD])) {
2777                $checkedIn = $this->dateConverter->convertToDisplayDate(
2778                    \DateTime::ATOM,
2779                    $item['fixedFields'][static::ITEM_CHECKIN_DATE_FIELD]['value']
2780                );
2781                if ($checkedIn == $today) {
2782                    $notes[] = $this->translate('Returned today');
2783                }
2784            }
2785        }
2786        return [$status, $duedate, $notes];
2787    }
2788
2789    /**
2790     * Determine whether an item is holdable
2791     *
2792     * @param array $item Item from Sierra
2793     * @param array $bib  Bib record from Sierra
2794     *
2795     * @return bool
2796     */
2797    protected function isHoldable(array $item, array $bib): bool
2798    {
2799        if (!$this->itemHoldsEnabled) {
2800            return false;
2801        }
2802
2803        if (null === ($bibLevel = $bib['bibLevel']['code'] ?? null)) {
2804            return false;
2805        }
2806        if (null === $this->itemHoldBibLevels) {
2807            // No item hold bib levels defined; allow only bib level NOT allowed
2808            // for title hold for back-compatibility:
2809            if (in_array($bibLevel, $this->titleHoldBibLevels)) {
2810                return false;
2811            }
2812        } else {
2813            // Bib level needs to be allowed for item level holds:
2814            if (!in_array($bibLevel, $this->itemHoldBibLevels)) {
2815                return false;
2816            }
2817        }
2818
2819        if (!empty($this->validHoldStatuses)) {
2820            [$status] = $this->getItemStatus($item);
2821            if (!in_array($status, $this->validHoldStatuses)) {
2822                return false;
2823            }
2824        }
2825        if (
2826            $this->itemHoldExcludedItemCodes
2827            && isset($item['fixedFields'][static::ITEM_ICODE2_FIELD])
2828        ) {
2829            $code = $item['fixedFields'][static::ITEM_ICODE2_FIELD]['value'];
2830            if (in_array($code, $this->itemHoldExcludedItemCodes)) {
2831                return false;
2832            }
2833        }
2834        if (
2835            $this->itemHoldExcludedItemTypes
2836            && isset($item['fixedFields'][static::ITEM_ITYPE_FIELD])
2837        ) {
2838            $code = $item['fixedFields'][static::ITEM_ITYPE_FIELD]['value'];
2839            if (in_array($code, $this->itemHoldExcludedItemTypes)) {
2840                return false;
2841            }
2842        }
2843        return true;
2844    }
2845
2846    /**
2847     * Get patron's blocks, if any
2848     *
2849     * @param array $patron Patron
2850     *
2851     * @return mixed        A boolean false if no blocks are in place and an array
2852     * of block reasons if blocks are in place
2853     */
2854    protected function getPatronBlocks($patron)
2855    {
2856        $patronId = $patron['id'];
2857        $cacheId = "blocks|$patronId";
2858        $blockReason = $this->getCachedData($cacheId);
2859        if (null === $blockReason) {
2860            $result = $this->makeRequest(
2861                [$this->apiBase, 'patrons', $patronId],
2862                [],
2863                'GET',
2864                $patron
2865            );
2866            if (
2867                !empty($result['blockInfo'])
2868                && trim($result['blockInfo']['code']) != '-'
2869            ) {
2870                $code = trim($result['blockInfo']['code']);
2871                $blockReason = [$this->patronBlockMappings[$code] ?? $code];
2872            } else {
2873                $blockReason = [];
2874            }
2875            $this->putCachedData($cacheId, $blockReason);
2876        }
2877        return empty($blockReason) ? false : $blockReason;
2878    }
2879
2880    /**
2881     * Pickup location sort function
2882     *
2883     * @param array $a First pickup location record to compare
2884     * @param array $b Second pickup location record to compare
2885     *
2886     * @return int
2887     */
2888    protected function pickupLocationSortFunction($a, $b)
2889    {
2890        $result = $this->getSorter()->compare(
2891            $a['locationDisplay'],
2892            $b['locationDisplay']
2893        );
2894        if ($result == 0) {
2895            $result = $a['locationID'] - $b['locationID'];
2896        }
2897        return $result;
2898    }
2899
2900    /**
2901     * Is the selected pickup location valid for the hold?
2902     *
2903     * @param string $pickUpLocation Selected pickup location
2904     * @param array  $patron         Patron information returned by the patronLogin
2905     * method.
2906     * @param array  $holdDetails    Details of hold being placed
2907     *
2908     * @return bool
2909     */
2910    protected function pickUpLocationIsValid($pickUpLocation, $patron, $holdDetails)
2911    {
2912        $pickUpLibs = $this->getPickUpLocations($patron, $holdDetails);
2913        foreach ($pickUpLibs as $location) {
2914            if ($location['locationID'] == $pickUpLocation) {
2915                return true;
2916            }
2917        }
2918        return false;
2919    }
2920
2921    /**
2922     * Hold Error
2923     *
2924     * Returns a Hold Error Message
2925     *
2926     * @param string $msg    An error message string
2927     * @param bool   $ilsMsg Whether the error is an ILS error message (needs formatting and any translations prefix)
2928     *
2929     * @return array An array with a success (boolean) and sysMessage key
2930     */
2931    protected function holdError($msg, bool $ilsMsg = true)
2932    {
2933        return [
2934            'success' => false,
2935            'sysMessage' => $ilsMsg ? $this->formatErrorMessage($msg) : $msg,
2936        ];
2937    }
2938
2939    /**
2940     * Format an error message received from Sierra
2941     *
2942     * @param string $msg An error message string
2943     *
2944     * @return string
2945     */
2946    protected function formatErrorMessage($msg)
2947    {
2948        // Remove prefix like "WebPAC Error" or "XCirc error"
2949        $msg = preg_replace('/.* [eE]rror\s*:\s*/', '', $msg);
2950        // Handle non-ascii characters that are returned in a wrongly encoded format
2951        // (e.g. {u00E4} instead of \u00E4)
2952        $msg = preg_replace_callback(
2953            '/\{u([0-9a-fA-F]{4})\}/',
2954            function ($matches) {
2955                return mb_convert_encoding(
2956                    pack('H*', $matches[1]),
2957                    'UTF-8',
2958                    'UCS-2BE'
2959                );
2960            },
2961            $msg
2962        );
2963        return ($this->config['Catalog']['translationPrefix'] ?? '') . $msg;
2964    }
2965
2966    /**
2967     * Get record data from cache and check that it has the requested fields
2968     *
2969     * @param string $cacheId Cache entry ID
2970     * @param array  $fields  Requested fields
2971     *
2972     * @return array Array with cached data if available, and fields (existing or
2973     * required)
2974     */
2975    protected function getCachedRecordData(string $cacheId, array $fields): array
2976    {
2977        if ($cached = $this->getCachedData($cacheId)) {
2978            if (!array_diff($fields, $cached['fields'])) {
2979                // We already have all required fields cached:
2980                return $cached;
2981            }
2982        }
2983        $cached = [
2984            'data' => [],
2985            'fields' => array_unique([...$fields, ...($cached['fields'] ?? [])]),
2986        ];
2987
2988        return $cached;
2989    }
2990
2991    /**
2992     * Insert record data and its field list into the cache
2993     *
2994     * @param string $cacheId Cache entry ID
2995     * @param array  $fields  Fields contained in the data
2996     * @param array  $data    Data
2997     * @param int    $ttl     Cache entry life time
2998     *
2999     * @return void
3000     */
3001    protected function putCachedRecordData(string $cacheId, array $fields, array $data, int $ttl): void
3002    {
3003        $this->putCachedData($cacheId, compact('data', 'fields'), $ttl);
3004    }
3005
3006    /**
3007     * Fetch fields for a bib record from Sierra
3008     *
3009     * Note: This method can return cached data
3010     *
3011     * @param string $id     Bib record id
3012     * @param ?array $fields Fields to request or null for defaults
3013     * @param ?array $patron Patron information, if available
3014     *
3015     * @return ?array
3016     */
3017    protected function getBibRecord(string $id, ?array $fields = null, ?array $patron = null): ?array
3018    {
3019        $result = $this->getBibRecords([$id], $fields, $patron);
3020        return $result[$id] ?? null;
3021    }
3022
3023    /**
3024     * Fetch fields for records from Sierra
3025     *
3026     * Note: This method can return cached data
3027     *
3028     * @param array  $ids    Record ids
3029     * @param string $type   Record type ('bib' or 'item')
3030     * @param array  $fields Fields to request
3031     * @param int    $ttl    Cache TTL
3032     * @param ?array $patron Patron information, if available
3033     *
3034     * @return ?array
3035     */
3036    protected function getRecords(
3037        array $ids,
3038        string $type,
3039        array $fields,
3040        int $ttl,
3041        ?array $patron = null
3042    ): ?array {
3043        $result = [];
3044        $requiredFields = $fields;
3045        foreach ($ids as &$id) {
3046            $cached = $this->getCachedRecordData("$type|$id", $fields);
3047            if ($cached['data']) {
3048                // We already have all required fields cached:
3049                $result[$id] = $cached['data'];
3050                $id = null;
3051            }
3052            $requiredFields = array_unique(
3053                [
3054                    ...$requiredFields,
3055                    ...$cached['fields'],
3056                ]
3057            );
3058        }
3059        // Unset reference:
3060        unset($id);
3061        $ids = array_filter($ids);
3062        // Return if we had all records in cache:
3063        if (!$ids) {
3064            return $result;
3065        }
3066        // Fetch requested fields as well as any cached fields to keep everything in
3067        // sync (note that Sierra has default limit of 50 that applies even if you
3068        // fetch a list of id's, so we need to override that):
3069        $records = $this->makeRequest(
3070            [$this->apiBase, $type . 's'],
3071            [
3072                'id' => implode(',', $ids),
3073                'fields' => implode(',', $requiredFields),
3074                'limit' => count($ids),
3075            ],
3076            'GET',
3077            $patron
3078        );
3079        foreach ($records['entries'] ?? [] as $record) {
3080            $id = $this->extractId($record['id']);
3081            $this->putCachedRecordData("$type|$id", $requiredFields, $record, $ttl);
3082            $result[$id] = $record;
3083        }
3084        return $result;
3085    }
3086
3087    /**
3088     * Fetch fields for bib records from Sierra
3089     *
3090     * Note: This method can return cached data
3091     *
3092     * @param array  $ids    Bib record ids
3093     * @param ?array $fields Fields to request or null for defaults
3094     * @param ?array $patron Patron information, if available
3095     *
3096     * @return ?array
3097     */
3098    protected function getBibRecords(array $ids, ?array $fields = null, ?array $patron = null): ?array
3099    {
3100        $fields ??= $this->defaultBibFields;
3101        return $this->getRecords($ids, 'bib', $fields, $this->bibCacheTTL, $patron);
3102    }
3103
3104    /**
3105     * Fetch fields for item records from Sierra
3106     *
3107     * Note: This method can return cached data
3108     *
3109     * @param array  $ids    Item record ids
3110     * @param ?array $fields Fields to request or null for defaults
3111     * @param ?array $patron Patron information, if available
3112     *
3113     * @return ?array
3114     */
3115    protected function getItemRecords(array $ids, ?array $fields = null, ?array $patron = null): ?array
3116    {
3117        $fields ??= $this->defaultItemFields;
3118        return $this->getRecords($ids, 'item', $fields, $this->itemCacheTTL, $patron);
3119    }
3120
3121    /**
3122     * Get all items for a bib record
3123     *
3124     * Note: This method can return cached data
3125     *
3126     * @param string $id     Bib record id
3127     * @param ?array $fields Fields to request or null for defaults
3128     * @param ?array $patron Patron information, if available
3129     *
3130     * @return array
3131     */
3132    protected function getItemsForBibRecord(
3133        string $id,
3134        ?array $fields = null,
3135        ?array $patron = null
3136    ): array {
3137        $fields ??= $this->defaultItemFields;
3138
3139        $cacheId = "bib-items|$id";
3140        $cached = $this->getCachedRecordData($cacheId, $fields);
3141        if ($cached['data']) {
3142            // We already have all required fields cached:
3143            return $cached['data'];
3144        }
3145        $items = [];
3146        $offset = 0;
3147        $limit = 50;
3148        $result = null;
3149        while (null === $result || $limit === $result['total']) {
3150            // Fetch requested fields as well as any cached fields to keep everything
3151            // in sync:
3152            $result = $this->makeRequest(
3153                [$this->apiBase, 'items'],
3154                [
3155                    'bibIds' => $this->extractBibId($id),
3156                    'deleted' => 'false',
3157                    'suppressed' => 'false',
3158                    'fields' => implode(',', $cached['fields']),
3159                    'limit' => $limit,
3160                    'offset' => $offset,
3161                ],
3162                'GET',
3163                $patron
3164            );
3165            if (empty($result['entries'])) {
3166                if (!empty($result['httpStatus']) && 404 !== $result['httpStatus']) {
3167                    $msg = "Item status request failed: {$result['httpStatus']}";
3168                    if (!empty($result['description'])) {
3169                        $msg .= " ({$result['description']})";
3170                    }
3171                    throw new ILSException($msg);
3172                }
3173                break;
3174            }
3175            $items = [...$items, ...$result['entries']];
3176            $offset += $limit;
3177        }
3178        $this->putCachedRecordData($cacheId, $cached['fields'], $items, $this->bibItemsCacheTTL);
3179        return $items;
3180    }
3181
3182    /**
3183     * Extract a numeric bib ID value from a string that may be prefixed.
3184     *
3185     * @param string $id Bib record id (with or without .b prefix)
3186     *
3187     * @return int
3188     */
3189    protected function extractBibId($id)
3190    {
3191        // If the .b prefix is found, strip it and the trailing checksum:
3192        return str_starts_with($id, '.b') ? substr($id, 2, -1) : $id;
3193    }
3194
3195    /**
3196     * If the system is configured to use full prefixed bib IDs, add the prefix
3197     * and checksum.
3198     *
3199     * @param int $id Bib ID that may need to be prefixed.
3200     *
3201     * @return string
3202     */
3203    protected function formatBibId($id)
3204    {
3205        // Simple case: prefixing is disabled, so return ID unmodified:
3206        if (!($this->config['Catalog']['use_prefixed_ids'] ?? false)) {
3207            return $id;
3208        }
3209
3210        // If we got this far, we need to generate a check digit:
3211        $multiplier = 2;
3212        $sum = 0;
3213        for ($x = strlen($id) - 1; $x >= 0; $x--) {
3214            $current = substr($id, $x, 1);
3215            $sum += $multiplier * intval($current);
3216            $multiplier++;
3217        }
3218        $checksum = $sum % 11;
3219        $finalChecksum = $checksum === 10 ? 'x' : $checksum;
3220        return '.b' . $id . $finalChecksum;
3221    }
3222
3223    /**
3224     * Check if we re using a patron-specific access token
3225     *
3226     * @return bool
3227     */
3228    protected function isPatronSpecificAccess()
3229    {
3230        return !empty($this->config['Catalog']['redirect_uri']);
3231    }
3232
3233    /**
3234     * Get patron information via authentication token when using patron-specific
3235     * access
3236     *
3237     * @param string $username The patron username
3238     * @param string $password The patron password
3239     *
3240     * @return array
3241     */
3242    protected function getPatronInformationFromAuthToken(
3243        string $username,
3244        string $password
3245    ): array {
3246        $credentials = [
3247            'cat_username' => $username,
3248            'cat_password' => $password,
3249        ];
3250        $result = $this->makeRequest(
3251            [$this->apiBase, 'info', 'token'],
3252            [],
3253            'GET',
3254            $credentials
3255        );
3256        if (null === $result) {
3257            return [];
3258        }
3259        if (empty($result['patronId'])) {
3260            throw new ILSException('No patronId in token response');
3261        }
3262
3263        $result = $this->makeRequest(
3264            [$this->apiBase, 'patrons', $result['patronId']],
3265            ['fields' => 'names,emails'],
3266            'GET',
3267            $credentials
3268        );
3269        if (null === $result || !empty($result['code'])) {
3270            return [];
3271        }
3272        return $result;
3273    }
3274
3275    /**
3276     * Authenticate a patron
3277     *
3278     * Returns patron information on success and null on failure
3279     *
3280     * @param string $username Username
3281     * @param string $password Password
3282     *
3283     * @return array|null
3284     */
3285    protected function authenticatePatron(
3286        string $username,
3287        ?string $password
3288    ): ?array {
3289        $authMethod = $this->config['Authentication']['method'] ?? 'native';
3290        $validationField = $this->config['Authentication']['patron_validation_field']
3291            ?? null;
3292        // patrons/auth endpoint is only supported on API version >= 6, without
3293        // custom validation configured:
3294        if (
3295            $this->apiVersion >= 6 && null !== $password
3296            && empty($validationField)
3297        ) {
3298            return $this->authenticatePatronV6($username, $password, $authMethod);
3299        }
3300
3301        if ('native' !== $authMethod) {
3302            $this->logError(
3303                'Sierra REST API level set too low for authentication method'
3304                . " '$authMethod'. Only 'native' is supported."
3305            );
3306            throw new ILSException('API level set too low');
3307        }
3308
3309        // Depending on validation settings, use either normal PIN-based auth,
3310        // or bypass PIN check and validate a different field.
3311        return empty($validationField)
3312            ? $this->authenticatePatronV5($username, $password)
3313            : $this->validatePatron(
3314                $this->authenticatePatronV5($username, null),
3315                $validationField,
3316                $password
3317            );
3318    }
3319
3320    /**
3321     * Perform extra validation of retrieved user, if configured to do so. Returns
3322     * patron data if value, null otherwise.
3323     *
3324     * @param ?array  $patron          Output of authenticatePatronV5()
3325     * @param string  $validationField Field to use for validation
3326     * @param ?string $password        Value to use in validation
3327     *
3328     * @return ?array
3329     * @throws \Exception
3330     */
3331    protected function validatePatron(
3332        ?array $patron,
3333        string $validationField,
3334        ?string $password
3335    ): ?array {
3336        // If the validation field is a valid, supported value, perform validation:
3337        if (in_array($validationField, ['email', 'name'])) {
3338            return in_array($password, $patron[$validationField . 's'] ?? [])
3339                ? $patron : null;
3340        }
3341        // Throw an exception if we got an unexpected configuration:
3342        throw new \Exception(
3343            "Unexpected patron_validation_field: $validationField"
3344        );
3345    }
3346
3347    /**
3348     * Authenticate a patron using the API version 5 endpoints
3349     *
3350     * Returns patron information on success and null on failure
3351     *
3352     * @param string $username Username
3353     * @param string $password Password
3354     *
3355     * @return array|null
3356     */
3357    protected function authenticatePatronV5(
3358        string $username,
3359        ?string $password
3360    ): ?array {
3361        // Validate a password unless it's null:
3362        if (null !== $password) {
3363            $request = [
3364                'barcode' => $username,
3365                'pin' => $password,
3366                'caseSensitivity' => false,
3367            ];
3368            try {
3369                // Note: hard-coded to use v5 API:
3370                $result = $this->makeRequest(
3371                    ['v5', 'patrons', 'validate'],
3372                    json_encode($request),
3373                    'POST',
3374                    false,
3375                    true
3376                );
3377            } catch (ILSException $e) {
3378                return null;
3379            }
3380            if (!$result || $result['statusCode'] != 204) {
3381                return null;
3382            }
3383        }
3384
3385        $varField = $this->config['Authentication']['patron_lookup_field'] ?? 'b';
3386        $result = $this->makeRequest(
3387            [$this->apiBase, 'patrons', 'find'],
3388            [
3389                'varFieldTag' => $varField,
3390                'varFieldContent' => $username,
3391                'fields' => 'names,emails',
3392            ]
3393        );
3394        if (!$result || !empty($result['code'])) {
3395            return null;
3396        }
3397        return $result;
3398    }
3399
3400    /**
3401     * Authenticate a patron using the API version 6 patrons/auth endpoint
3402     *
3403     * Returns patron information on success and null on failure
3404     *
3405     * @param string $username Username
3406     * @param string $password Password
3407     * @param string $method   Authentication method
3408     *
3409     * @return array|null
3410     */
3411    protected function authenticatePatronV6(
3412        string $username,
3413        string $password,
3414        string $method
3415    ): ?array {
3416        $request = [
3417            'authMethod' => $method,
3418            'patronId' => $username,
3419            'patronSecret' => $password,
3420        ];
3421        $result = $this->makeRequest(
3422            ['v6', 'patrons', 'auth'],
3423            json_encode($request),
3424            'POST'
3425        );
3426        if (!$result || !empty($result['code'])) {
3427            return null;
3428        }
3429        $result = $this->makeRequest(
3430            [$this->apiBase, 'patrons', $result],
3431            ['fields' => 'names,emails']
3432        );
3433        if (!$result || !empty($result['code'])) {
3434            return null;
3435        }
3436        return $result;
3437    }
3438
3439    /**
3440     * Get items and their bibs for an array of transactions
3441     *
3442     * @param array $transactions Transaction list
3443     * @param array $patron       The patron array from patronLogin
3444     *
3445     * @return array
3446     */
3447    protected function getItemsWithBibsForTransactions(
3448        array $transactions,
3449        array $patron
3450    ): array {
3451        if (!$transactions) {
3452            return [];
3453        }
3454        // Fetch items and collect bib id mappings if available:
3455        $itemIds = [];
3456        $bibIdsToItems = [];
3457        foreach ($transactions as $transaction) {
3458            $itemId = $this->extractId($transaction['item']);
3459            $itemIds[] = $itemId;
3460            // Historical transactions include the bib id. Collect them here so that
3461            // we can get the bib data even if the item doesn't exist anymore:
3462            if ($bibId = $transaction['bib'] ?? null) {
3463                $bibIdsToItems[$this->extractId($bibId)][$itemId] = true;
3464            }
3465        }
3466        if ($this->config['InnReach']['enabled'] ?? false) {
3467            foreach ($itemIds as $key => $iRId) {
3468                if (strstr($iRId, $this->config['InnReach']['identifier'])) {
3469                    unset($itemIds[$key]);
3470                }
3471            }
3472        }
3473        // Get items and collect further bib id mappings:
3474        $items = $this->getItemRecords($itemIds, null, $patron);
3475        foreach ($items as $itemId => $item) {
3476            if ($bibId = (string)($item['bibIds'][0] ?? '')) {
3477                // Collect all item id's for each bib:
3478                $bibIdsToItems[$bibId][$itemId] = true;
3479            }
3480        }
3481        // Fetch bibs for the items:
3482        foreach ($this->getBibRecords(array_keys($bibIdsToItems), null, $patron) as $bib) {
3483            // Add bib data to the items:
3484            foreach (array_keys($bibIdsToItems[(string)$bib['id']]) as $itemId) {
3485                $items[$itemId]['bib'] = $bib;
3486            }
3487        }
3488
3489        return $items;
3490    }
3491
3492    /**
3493     * Check if bib matches title hold rules
3494     *
3495     * @param array $bib    Bibliographic record fields
3496     * @param array $patron An array of patron data
3497     *
3498     * @return bool True if request is valid, false if not
3499     */
3500    protected function checkTitleHoldRules(array $bib, array $patron): bool
3501    {
3502        if (!$this->titleHoldRules) {
3503            return true;
3504        }
3505
3506        if (
3507            in_array('order', $this->titleHoldRules)
3508            && !empty($bib['orders'])
3509        ) {
3510            return true;
3511        }
3512
3513        if (in_array('item', $this->titleHoldRules)) {
3514            $items = $this->getItemsForBibRecord($bib['id'], null, $patron);
3515            foreach ($items as $item) {
3516                if (!empty($this->titleHoldValidHoldStatuses)) {
3517                    [$status] = $this->getItemStatus($item);
3518                    if (!in_array($status, $this->titleHoldValidHoldStatuses)) {
3519                        continue;
3520                    }
3521                }
3522                if (
3523                    $this->titleHoldExcludedItemCodes
3524                    && isset($item['fixedFields'][static::ITEM_ICODE2_FIELD])
3525                ) {
3526                    $code = $item['fixedFields'][static::ITEM_ICODE2_FIELD]['value'];
3527                    if (in_array($code, $this->titleHoldExcludedItemCodes)) {
3528                        continue;
3529                    }
3530                }
3531                if (
3532                    $this->titleHoldExcludedItemTypes
3533                    && isset($item['fixedFields'][static::ITEM_ITYPE_FIELD])
3534                ) {
3535                    $code = $item['fixedFields'][static::ITEM_ITYPE_FIELD]['value'];
3536                    if (in_array($code, $this->titleHoldExcludedItemTypes)) {
3537                        continue;
3538                    }
3539                }
3540                return true;
3541            }
3542        }
3543        return false;
3544    }
3545
3546    /**
3547     * Gets title information for holds placed in an INN-Reach system
3548     *
3549     * @param $holdId the id of the hold from Sierra
3550     * @param $bibId  the id of the bib from Sierra
3551     *
3552     * @return array
3553     *
3554     * @throws ILSException
3555     */
3556    protected function getInnReachHoldTitleInfoFromId($holdId, $bibId): array
3557    {
3558        $db = $this->getInnReachDb();
3559        $titleInfo = [];
3560        if ($db) {
3561            try {
3562                $query = 'SELECT
3563                        bib_record_property.best_title as title,
3564                        bib_record_property.best_author as author,
3565                        --hold.status, -- this shows sierra hold status not inn-reach status
3566                        bib_record_property.best_title_norm as sort_title
3567                    FROM
3568                        sierra_view.hold,
3569                        sierra_view.bib_record_item_record_link,
3570                        sierra_view.bib_record_property
3571                    WHERE
3572                        hold.id = $1
3573                    AND hold.is_ir=true
3574                    AND hold.record_id = bib_record_item_record_link.item_record_id
3575                    AND bib_record_item_record_link.bib_record_id = bib_record_property.bib_record_id';
3576                pg_prepare($this->innReachDb, 'prep_query', $query);
3577                $results = pg_execute($this->innReachDb, 'prep_query', [$holdId]);
3578                if ($result = pg_fetch_array($results, 0)) {
3579                    $titleInfo['id'] = $bibId;
3580                    $titleInfo['title'] = $result[0];
3581                    $titleInfo['author'] = $result[1];
3582                }
3583            } catch (\Exception $e) {
3584                $this->throwAsIlsException($e);
3585            }
3586        } else {
3587            $titleInfo['id'] = '';
3588            $titleInfo['title'] = 'Unknown Title';
3589            $titleInfo['author'] = 'Unknown Author';
3590        }
3591        return $titleInfo;
3592    }
3593
3594    /**
3595     * Gets title information for checked out items from INN-Reach systems
3596     *
3597     * @param $checkOutId the id of the checkout from Sierra
3598     * @param $bibId      the id of the bib from Sierra
3599     *
3600     * @return array
3601     *
3602     * @throws ILSException
3603     */
3604    protected function getInnReachCheckoutTitleInfoFromId($checkOutId, $bibId): array
3605    {
3606        $db = $this->getInnReachDb();
3607        $titleInfo = [];
3608        if ($db) {
3609            try {
3610                $query = 'SELECT
3611  bib_record_property.best_title as title,
3612  bib_record_property.best_author as author,
3613  bib_record_property.best_title_norm as sort_title
3614FROM
3615  sierra_view.checkout,
3616  sierra_view.bib_record_item_record_link,
3617  sierra_view.bib_record_property
3618WHERE
3619  checkout.id = $1
3620  AND checkout.item_record_id = bib_record_item_record_link.item_record_id
3621  AND bib_record_item_record_link.bib_record_id = bib_record_property.bib_record_id';
3622                pg_prepare($this->innReachDb, 'prep_query', $query);
3623                $results = pg_execute($this->innReachDb, 'prep_query', [$checkOutId]);
3624                if ($result = pg_fetch_array($results, 0)) {
3625                    $titleInfo['id'] = $bibId;
3626                    $titleInfo['title'] = $result[0];
3627                    $titleInfo['author'] = $result[1];
3628                }
3629            } catch (\Exception $e) {
3630                $this->throwAsIlsException($e);
3631            }
3632        } else {
3633            $titleInfo['id'] = '';
3634            $titleInfo['title'] = 'Unknown Title';
3635            $titleInfo['author'] = 'Unknown Author';
3636        }
3637        return $titleInfo;
3638    }
3639}