Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.30% covered (warning)
89.30%
1010 / 1131
58.33% covered (warning)
58.33%
49 / 84
CRAP
0.00% covered (danger)
0.00%
0 / 1
XCNCIP2
89.30% covered (warning)
89.30%
1010 / 1131
58.33% covered (warning)
58.33%
49 / 84
346.06
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 init
76.60% covered (warning)
76.60%
36 / 47
0.00% covered (danger)
0.00%
0 / 1
15.17
 loadPickUpLocations
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 loadPickUpLocationsFromFile
73.33% covered (warning)
73.33%
11 / 15
0.00% covered (danger)
0.00%
0 / 1
5.47
 loadPickUpLocationsFromNcip
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
4
 sendRequest
64.71% covered (warning)
64.71%
22 / 34
0.00% covered (danger)
0.00%
0 / 1
12.56
 getOAuth2Token
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 getCacheKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStatusForChunk
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
4
 getHoldingsForChunk
98.55% covered (success)
98.55%
68 / 69
0.00% covered (danger)
0.00%
0 / 1
8
 getStatus
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStatusRequest
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 getStatuses
90.38% covered (success)
90.38%
47 / 52
0.00% covered (danger)
0.00%
0 / 1
11.11
 getConsortialHoldings
100.00% covered (success)
100.00%
54 / 54
100.00% covered (success)
100.00%
1 / 1
11
 getHolding
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getPurchaseHistory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 patronLogin
97.22% covered (success)
97.22%
35 / 36
0.00% covered (danger)
0.00%
0 / 1
4
 getMyTransactions
90.67% covered (success)
90.67%
68 / 75
0.00% covered (danger)
0.00%
0 / 1
20.33
 getMyFines
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
3
 getMyRequests
96.61% covered (success)
96.61%
57 / 59
0.00% covered (danger)
0.00%
0 / 1
12
 getMyHolds
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMyProfile
98.65% covered (success)
98.65%
73 / 74
0.00% covered (danger)
0.00%
0 / 1
9
 getNewItems
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFunds
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDepartments
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getInstructors
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCourses
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 findReserves
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSuppressedRecords
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getConfig
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
30
 getDefaultPickUpLocation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPatronBlocks
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 isPatronBlocked
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getPickUpLocations
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getMyStorageRetrievalRequests
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkStorageRetrievalRequestIsValid
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 placeStorageRetrievalRequest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRenewDetails
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 placeHold
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 placeRequest
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
1 / 1
5
 handleCancelRequest
97.87% covered (success)
97.87%
46 / 47
0.00% covered (danger)
0.00%
0 / 1
6
 cancelHolds
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCancelRequestDetails
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getCancelHoldDetails
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 cancelStorageRetrievalRequests
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCancelStorageRetrievalRequestDetails
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 renewMyItems
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
5
 getAccountBlocks
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getCancelRequest
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 getRequest
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 getRenewRequest
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getLookupUserRequest
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getLookupAgencyRequest
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 getLookupItemRequest
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 getInitiationHeaderXml
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 getNCIPMessageStart
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getAuthenticationInputXml
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 getItemIdXml
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getUserIdXml
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getRequestTypeXml
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getBibliographicId
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 checkResponseForError
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 registerNamespaceFor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 displayDate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 displayTime
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 convertDateOrTime
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 getHoldType
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isAvailable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isRequestCancelled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkRequestType
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 isItemHoldable
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 determineToAgencyId
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getLookupUserResponse
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
2.00
 getLookupUserExtras
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 parseXml
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 parseProblem
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 getProblemDescription
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
6.01
 schemeAttr
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 element
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 parseLocationInstance
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 translateMessage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 invalidateResponseCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getBibs
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
4.00
 isNextItemTokenEmpty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * XC NCIP Toolkit (v2) ILS Driver
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2011.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  ILS_Drivers
25 * @author   Demian Katz <demian.katz@villanova.edu>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
28 */
29
30namespace VuFind\ILS\Driver;
31
32use VuFind\Config\PathResolver;
33use VuFind\Date\DateException;
34use VuFind\Exception\AuthToken as AuthTokenException;
35use VuFind\Exception\ILS as ILSException;
36
37use function array_key_exists;
38use function count;
39use function in_array;
40use function is_array;
41
42/**
43 * XC NCIP Toolkit (v2) ILS Driver
44 *
45 * @category VuFind
46 * @package  ILS_Drivers
47 * @author   Demian Katz <demian.katz@villanova.edu>
48 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
49 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
50 */
51class XCNCIP2 extends AbstractBase implements
52    \VuFindHttp\HttpServiceAwareInterface,
53    \Laminas\Log\LoggerAwareInterface,
54    \VuFind\I18n\Translator\TranslatorAwareInterface
55{
56    use \VuFindHttp\HttpServiceAwareTrait;
57    use \VuFind\Cache\CacheTrait;
58    use \VuFind\ILS\Driver\OAuth2TokenTrait;
59    use \VuFind\I18n\Translator\TranslatorAwareTrait;
60
61    /**
62     * Is this a consortium? Default: false
63     *
64     * @var bool
65     */
66    protected $consortium = false;
67
68    /**
69     * Agency definitions (consortial) - Array list of consortium members
70     *
71     * @var array
72     */
73    protected $agency = [];
74
75    /**
76     * NCIP server URL
77     *
78     * @var string
79     */
80    protected $url;
81
82    /**
83     * Pickup locations
84     *
85     * @var array
86     */
87    protected $pickupLocations = null;
88
89    /**
90     * Date converter object
91     *
92     * @var \VuFind\Date\Converter
93     */
94    protected $dateConverter;
95
96    /**
97     * Config file path resolver
98     *
99     * @var PathResolver
100     */
101    protected $pathResolver;
102
103    /**
104     * From agency id
105     *
106     * @var string
107     */
108    protected $fromAgency = null;
109
110    /**
111     * Statuses of available items lowercased status string from CirculationStatus
112     * NCIP element
113     *
114     * @var string[]
115     */
116    protected $availableStatuses = ['not charged', 'available on shelf'];
117
118    /**
119     * Statuses of active requests, lowercased status strings from RequestStatusType
120     * NCIP element
121     *
122     * @var string[]
123     */
124    protected $activeRequestStatuses = ['available for pickup', 'in process'];
125
126    /**
127     * Lowercased status string for requests available for pickup by patron
128     *
129     * @var string
130     */
131    protected $requestAvailableStatus = 'available for pickup';
132
133    /**
134     * Lowercased request type strings identifying holds
135     *
136     * @var string[]
137     */
138    protected $holdRequestTypes = ['hold', 'recall'];
139
140    /**
141     * Lowercased request type strings identifying storage retrievals
142     *
143     * @var string[]
144     */
145    protected $storageRetrievalRequestTypes = ['stack retrieval'];
146
147    /**
148     * Lowercased item use restriction types we consider to be holdable
149     *
150     * @var string[]
151     */
152    protected $notHoldableRestriction = ['not for loan'];
153
154    /**
155     * Lowercased circulation statuses we consider not be holdable
156     *
157     * @var string[]
158     */
159    protected $notHoldableStatuses = [
160        'circulation status undefined', 'not available', 'lost',
161    ];
162
163    /**
164     * Are renewals disabled for this driver instance? Defaults to false
165     *
166     * @var bool
167     */
168    protected $disableRenewals = false;
169
170    /**
171     * Which subelements of Problem element show to user when placing hold fails.
172     * Possible values are: 'ProblemType', 'ProblemDetail', 'ProblemElement',
173     * 'ProblemValue'
174     *
175     * @var string[]
176     */
177    protected $holdProblemsDisplay = ['ProblemType'];
178
179    /**
180     * Schemes preset for certain elements. See implementation profile:
181     * http://www.ncip.info/uploads/7/1/4/6/7146749/z39-83-2-2012_ncip.pdf
182     *
183     * @var string[]
184     */
185    protected $schemes = [
186        'AgencyElementType' =>
187            'http://www.niso.org/ncip/v1_0/imp1/schemes/agencyelementtype/' .
188            'agencyelementtype.scm',
189        'AuthenticationDataFormatType' =>
190            'http://www.iana.org/assignments/media-types/',
191        'AuthenticationInputType' =>
192            'http://www.niso.org/ncip/v1_0/imp1/schemes/authenticationinputtype/' .
193            'authenticationinputype.scm',
194        'BibliographicItemIdentifierCode' =>
195            'http://www.niso.org/ncip/v1_0/imp1/schemes/' .
196            'bibliographicitemidentifiercode/bibliographicitemidentifiercode.scm',
197        'ItemElementType' =>
198            'http://www.niso.org/ncip/v1_0/schemes/itemelementtype/' .
199            'itemelementtype.scm',
200        'RequestScopeType' =>
201            'http://www.niso.org/ncip/v1_0/imp1/schemes/requestscopetype/' .
202            'requestscopetype.scm',
203        'RequestType' =>
204            'http://www.niso.org/ncip/v1_0/imp1/schemes/requesttype/requesttype.scm',
205        'UserElementType' =>
206            'http://www.niso.org/ncip/v1_0/schemes/userelementtype/' .
207            'userelementtype.scm',
208    ];
209
210    /**
211     * L1 cache for NCIP responses to save some http connections. Responses are
212     * save as in following structure:
213     * [ 'ServiceName' => [ 'someId' => \SimpleXMLElement ] ]
214     *
215     * @var array
216     */
217    protected $responses = [
218        'LookupUser' => [],
219    ];
220
221    /**
222     * If the NCIP need an authorization using OAuth2
223     *
224     * @var bool
225     */
226    protected $useOAuth2 = false;
227
228    /**
229     * Use HTTP basic authorization when getting OAuth2 token
230     *
231     * @var bool
232     */
233    protected $tokenBasicAuth = false;
234
235    /**
236     * If the NCIP need an authorization using HTTP Basic
237     *
238     * @var bool
239     */
240    protected $useHttpBasic = false;
241
242    /**
243     * HTTP Basic username
244     *
245     * @var string
246     */
247    protected $username;
248
249    /**
250     * HTTP Basic password
251     *
252     * @var string
253     */
254    protected $password;
255
256    /**
257     * Mapping block messages from NCIP API to VuFind internal values
258     *
259     * @var array
260     */
261    protected $blockCodes = [
262        'Block Check Out' => 'checkout_block',
263        'Block Electronic Resource Access' => 'electronic_resources_block',
264        'Block Hold' => 'requests_blocked',
265        'Block Recall' => 'requests_blocked',
266        'Block Renewal' => 'renewal_block',
267        'Block Request Item' => 'requests_blocked',
268        'Trap For Lost Card' => 'lost_card',
269        'Trap For Message' => 'message_from_library',
270        'Trap For Pickup' => 'available_for_pickup_notification',
271    ];
272
273    /**
274     * Domain used to translate messages from ILS
275     *
276     * @var string
277     */
278    protected $translationDomain = 'ILSMessages';
279
280    /**
281     * Other than 2xx HTTP status codes, which could be accepted as correct response.
282     * Some NCIP servers could return some 4xx codes similar to REST API (like 404
283     * Not found) altogether with correct XML in response body.
284     *
285     * @var array
286     */
287    protected $otherAcceptedHttpStatusCodes = [];
288
289    /**
290     * Max number of pages taken from API at once. Sometimes NCIP responders could
291     * paginate even if we want all data at one time. We then ask for all pages, but
292     * it could possibly lead to long response times.
293     *
294     * @var int
295     */
296    protected $maxNumberOfPages;
297
298    /**
299     * Some ItemUseRestrictionType values could be useful as status. This property controls which values from
300     * ItemRestrictionType should replace the status value in response of getHolding method.
301     *
302     * @var array
303     */
304    protected $itemUseRestrictionTypesForStatus = [];
305
306    /**
307     * Constructor
308     *
309     * @param \VuFind\Date\Converter $dateConverter Date converter object
310     * @param PathResolver           $pathResolver  Config file path resolver
311     */
312    public function __construct(
313        \VuFind\Date\Converter $dateConverter,
314        PathResolver $pathResolver = null
315    ) {
316        $this->dateConverter = $dateConverter;
317        $this->pathResolver = $pathResolver;
318    }
319
320    /**
321     * Initialize the driver.
322     *
323     * Validate configuration and perform all resource-intensive tasks needed to
324     * make the driver active.
325     *
326     * @throws ILSException
327     * @return void
328     */
329    public function init()
330    {
331        // Validate config
332        $required = ['url', 'agency'];
333        foreach ($required as $current) {
334            if (!isset($this->config['Catalog'][$current])) {
335                throw new ILSException("Missing Catalog/{$current} config setting.");
336            }
337        }
338
339        $this->url = $this->config['Catalog']['url'];
340        $this->fromAgency = $this->config['Catalog']['fromAgency'] ?? null;
341        if ($this->config['Catalog']['consortium'] ?? false) {
342            $this->consortium = true;
343            foreach ($this->config['Catalog']['agency'] ?? [] as $agency) {
344                $this->agency[$agency] = 1;
345            }
346        } else {
347            $this->consortium = false;
348            if (is_array($this->config['Catalog']['agency'] ?? null)) {
349                $this->agency[$this->config['Catalog']['agency'][0]] = 1;
350            } else {
351                $this->agency[$this->config['Catalog']['agency']] = 1;
352            }
353        }
354        $this->disableRenewals
355            = $this->config['Catalog']['disableRenewals'] ?? false;
356
357        $this->useOAuth2 = ($this->config['Catalog']['tokenEndpoint'] ?? false)
358            && ($this->config['Catalog']['clientId'] ?? false)
359            && ($this->config['Catalog']['clientSecret'] ?? false);
360        $this->tokenBasicAuth = $this->config['Catalog']['tokenBasicAuth'] ?? false;
361
362        $this->useHttpBasic = ($this->config['Catalog']['httpBasicAuth'] ?? false)
363            && ($this->config['Catalog']['username'] ?? false)
364            && ($this->config['Catalog']['password'] ?? false);
365
366        $this->username = $this->config['Catalog']['username'] ?? '';
367        $this->password = $this->config['Catalog']['password'] ?? '';
368
369        if (isset($this->config['Catalog']['translationDomain'])) {
370            $this->translationDomain
371                = $this->config['Catalog']['translationDomain'];
372        }
373
374        if (isset($this->config['Catalog']['otherAcceptedHttpStatusCodes'])) {
375            $otherAcceptedHttpStatusCodes
376                = explode(
377                    ',',
378                    $this->config['Catalog']['otherAcceptedHttpStatusCodes']
379                );
380            $this->otherAcceptedHttpStatusCodes = array_map(
381                'trim',
382                $otherAcceptedHttpStatusCodes
383            );
384        }
385        $this->maxNumberOfPages = $this->config['Catalog']['maxNumberOfPages'] ?? 0;
386        if (isset($this->config['Catalog']['holdProblemsDisplay'])) {
387            $holdProblemsDisplay
388                = explode(
389                    ',',
390                    $this->config['Catalog']['holdProblemsDisplay']
391                );
392            $this->holdProblemsDisplay = array_map('trim', $holdProblemsDisplay);
393        }
394
395        $this->itemUseRestrictionTypesForStatus
396            = (array)($this->config['Catalog']['itemUseRestrictionTypesForStatus'] ?? []);
397    }
398
399    /**
400     * Load pickup locations from file or from NCIP responder - it depends on
401     * configuration
402     *
403     * @throws ILSException
404     * @return void
405     */
406    public function loadPickUpLocations()
407    {
408        $filename = $this->config['Catalog']['pickupLocationsFile'] ?? null;
409        if ($filename) {
410            $this->loadPickUpLocationsFromFile($filename);
411        } elseif ($this->config['Catalog']['pickupLocationsFromNCIP'] ?? false) {
412            $this->loadPickUpLocationsFromNcip();
413        } else {
414            $this->pickupLocations = [];
415        }
416    }
417
418    /**
419     * Loads pickup location information from configuration file.
420     *
421     * @param string $filename File to load from
422     *
423     * @throws ILSException
424     * @return void
425     */
426    protected function loadPickUpLocationsFromFile($filename)
427    {
428        // Load pickup locations file:
429        $pickupLocationsFile = $this->pathResolver
430            ? $this->pathResolver->getConfigPath($filename)
431            : \VuFind\Config\Locator::getConfigPath($filename);
432        if (!file_exists($pickupLocationsFile)) {
433            throw new ILSException(
434                "Cannot load pickup locations file: {$pickupLocationsFile}."
435            );
436        }
437        if (($handle = fopen($pickupLocationsFile, 'r')) !== false) {
438            while (($data = fgetcsv($handle)) !== false) {
439                $agencyId = $data[0] . '|' . $data[1];
440                $this->pickupLocations[$agencyId] = [
441                    'locationID' => $agencyId,
442                    'locationDisplay' => $data[2],
443                ];
444            }
445            fclose($handle);
446        }
447    }
448
449    /**
450     * Loads pickup location information from LookupAgency NCIP service.
451     *
452     * @return void
453     */
454    public function loadPickUpLocationsFromNcip()
455    {
456        $request = $this->getLookupAgencyRequest();
457        $response = $this->sendRequest($request);
458
459        $return = [];
460
461        $agencyId = $response->xpath('ns1:LookupAgencyResponse/ns1:AgencyId');
462        $agencyId = (string)($agencyId[0] ?? '');
463        $locations = $response->xpath(
464            'ns1:LookupAgencyResponse/ns1:Ext/ns1:LocationName/' .
465            'ns1:LocationNameInstance'
466        );
467        foreach ($locations as $loc) {
468            $this->registerNamespaceFor($loc);
469            $id = $loc->xpath('ns1:LocationNameLevel');
470            $name = $loc->xpath('ns1:LocationNameValue');
471            if (empty($id) || empty($name)) {
472                continue;
473            }
474            $location = [
475                'locationID' => $agencyId . '|' . (string)$id[0],
476                'locationDisplay' => (string)$name[0],
477            ];
478            $return[] = $location;
479        }
480        $this->pickupLocations = $return;
481    }
482
483    /**
484     * Send an NCIP request.
485     *
486     * @param string $xml XML request document
487     *
488     * @return \SimpleXMLElement SimpleXMLElement parsed from response
489     * @throws ILSException
490     */
491    protected function sendRequest($xml)
492    {
493        $this->debug('Sending NCIP request: ' . $xml);
494        $client = $this->httpService->createClient($this->url);
495        if ($this->useOAuth2) {
496            $client->getRequest()->getHeaders()
497                ->addHeaderLine('Authorization', $this->getOAuth2Token());
498        } elseif ($this->useHttpBasic) {
499            $client->setAuth($this->username, $this->password);
500        }
501        // Set timeout value
502        $timeout = $this->config['Catalog']['http_timeout'] ?? 30;
503        $client->setOptions(['timeout' => $timeout]);
504        $client->setRawBody($xml);
505        $client->setEncType('application/xml; charset=UTF-8');
506        $client->setMethod('POST');
507        // Make the NCIP request:
508        try {
509            $result = $client->send();
510        } catch (\Exception $e) {
511            $this->logError('Error in NCIP communication: ' . $e->getMessage());
512            $this->throwAsIlsException($e, 'Problem with NCIP API');
513        }
514
515        // If we get a 401, we need to renew the access token and try again
516        if ($this->useOAuth2 && $result->getStatusCode() == 401) {
517            $client->getRequest()->getHeaders()
518                ->addHeaderLine('Authorization', $this->getOAuth2Token(true));
519            try {
520                $result = $client->send();
521            } catch (\Exception $e) {
522                $this->logError('Error in NCIP communication: ' . $e->getMessage());
523                $this->throwAsIlsException($e, 'Problem with NCIP API');
524            }
525        }
526
527        if (
528            !$result->isSuccess()
529            && !in_array(
530                $result->getStatusCode(),
531                $this->otherAcceptedHttpStatusCodes
532            )
533        ) {
534            throw new ILSException(
535                'HTTP error: ' . $this->parseProblem($result->getBody())
536            );
537        }
538
539        // Process the NCIP response:
540        $response = $result->getBody();
541        $this->debug('Got NCIP response: ' . $response);
542        return $this->parseXml($response);
543    }
544
545    /**
546     * Get a new or cached OAuth2 token (type + token)
547     *
548     * @param bool $renew Force renewal of token
549     *
550     * @return string
551     */
552    protected function getOAuth2Token($renew = false)
553    {
554        $cacheKey = 'oauth';
555
556        if (!$renew) {
557            $token = $this->getCachedData($cacheKey);
558            if ($token) {
559                return $token;
560            }
561        }
562
563        try {
564            $token = $this->getNewOAuth2Token(
565                $this->config['Catalog']['tokenEndpoint'],
566                $this->config['Catalog']['clientId'],
567                $this->config['Catalog']['clientSecret'],
568                $this->config['Catalog']['grantType'] ?? 'client_credentials',
569                $this->tokenBasicAuth
570            );
571        } catch (AuthTokenException $exception) {
572            $this->throwAsIlsException(
573                $exception,
574                'Problem with NCIP API authorization: ' . $exception->getMessage()
575            );
576        }
577
578        $this->putCachedData(
579            $cacheKey,
580            $token->getHeaderValue(),
581            $token->getExpiresIn()
582        );
583
584        return $token->getHeaderValue();
585    }
586
587    /**
588     * Method to ensure uniform cache keys for cached VuFind objects.
589     *
590     * @param string|null $suffix Optional suffix that will get appended to the
591     * object class name calling getCacheKey()
592     *
593     * @return string
594     */
595    protected function getCacheKey($suffix = null)
596    {
597        return 'XCNCIP2' . '-' . md5($this->url . $suffix);
598    }
599
600    /**
601     * Given a chunk of the availability response, extract the values needed
602     * by VuFind.
603     *
604     * @param SimpleXMLElement $current Current LUIS holding chunk.
605     *
606     * @return array of status information for this holding
607     */
608    protected function getStatusForChunk($current)
609    {
610        $this->registerNamespaceFor($current);
611        $status = $current->xpath(
612            'ns1:ItemOptionalFields/ns1:CirculationStatus'
613        );
614        $status = (string)($status[0] ?? '');
615
616        $itemCallNo = $current->xpath(
617            'ns1:ItemOptionalFields/ns1:ItemDescription/ns1:CallNumber'
618        );
619        $itemCallNo = !empty($itemCallNo) ? (string)$itemCallNo[0] : null;
620
621        $location = $current->xpath(
622            'ns1:ItemOptionalFields/ns1:Location/ns1:LocationName/' .
623            'ns1:LocationNameInstance/ns1:LocationNameValue'
624        );
625        $location = !empty($location) ? (string)$location[0] : null;
626
627        $return = [
628            'status' => $status,
629            'location' => $location,
630            'callnumber' => $itemCallNo,
631            'availability' => $this->isAvailable($status),
632            'reserve' => 'N',       // not supported
633        ];
634        if (strtolower($status) === 'circulation status undefined') {
635            $return['use_unknown_message'] = true;
636        }
637        return $return;
638    }
639
640    /**
641     * Given a chunk of the availability response, extract the values needed
642     * by VuFind.
643     *
644     * @param \SimpleXMLElement $current     Current ItemInformation element
645     * @param string            $aggregateId (Aggregate) ID of the consortial record
646     * @param string            $bibId       Bib ID of one of the consortial
647     * record's source record(s)
648     * @param array             $patron      Patron array from patronLogin
649     *
650     * @return array
651     * @throws ILSException
652     */
653    protected function getHoldingsForChunk(
654        $current,
655        $aggregateId = null,
656        $bibId = null,
657        $patron = null
658    ) {
659        $this->registerNamespaceFor($current);
660
661        // Extract details from the XML:
662        $status = $current->xpath(
663            'ns1:ItemOptionalFields/ns1:CirculationStatus'
664        );
665        $status = (string)($status[0] ?? '');
666
667        $itemUseRestrictionType = $current->xpath('ns1:ItemOptionalFields/ns1:ItemUseRestrictionType');
668        $itemUseRestrictionType = (string)($itemUseRestrictionType[0] ?? '');
669
670        if (in_array($itemUseRestrictionType, $this->itemUseRestrictionTypesForStatus)) {
671            $status = $itemUseRestrictionType;
672        }
673
674        $itemId = $current->xpath('ns1:ItemId/ns1:ItemIdentifierValue');
675        $itemId = (string)($itemId[0] ?? '');
676        $itemType = $current->xpath('ns1:ItemId/ns1:ItemIdentifierType');
677        $itemType = (string)($itemType[0] ?? '');
678
679        $itemAgencyId = $current->xpath('ns1:ItemId/ns1:AgencyId');
680        $itemAgencyId = !empty($itemAgencyId) ? ((string)$itemAgencyId[0]) : null;
681        $itemAgencyId = $this->determineToAgencyId($itemAgencyId);
682
683        // Pick out the permanent location (TODO: better smarts for dealing with
684        // temporary locations and multi-level location names):
685        // $locationNodes = $current->xpath('ns1:HoldingsSet/ns1:Location');
686        // $location = '';
687        // foreach ($locationNodes as $curLoc) {
688        //     $type = $curLoc->xpath('ns1:LocationType');
689        //     if ((string)$type[0] == 'Permanent') {
690        //         $tmp = $curLoc->xpath(
691        //             'ns1:LocationName/ns1:LocationNameInstance' .
692        //             '/ns1:LocationNameValue'
693        //         );
694        //         $location = (string)$tmp[0];
695        //     }
696        // }
697
698        $locations = $current->xpath(
699            'ns1:ItemOptionalFields/ns1:Location/ns1:LocationName/' .
700            'ns1:LocationNameInstance'
701        );
702        [$location, $collection] = $this->parseLocationInstance($locations);
703
704        $itemCallNo = $current->xpath(
705            'ns1:ItemOptionalFields/ns1:ItemDescription/ns1:CallNumber'
706        );
707        $itemCallNo = (string)($itemCallNo[0] ?? '');
708
709        $number = $current->xpath(
710            'ns1:ItemOptionalFields/ns1:ItemDescription/' .
711            'ns1:CopyNumber'
712        );
713        $number = (string)($number[0] ?? '');
714
715        $volume = $current->xpath(
716            'ns1:ItemOptionalFields/ns1:ItemDescription/' .
717            'ns1:HoldingsInformation/ns1:UnstructuredHoldingsData'
718        );
719        $volume = (string)($volume[0] ?? '');
720
721        $dateDue = $current->xpath(
722            'ns1:DateDue' .
723            '| ' .
724            'ns1:ItemOptionalFields/ns1:DateDue'
725        );
726        $dateDue = !empty($dateDue)
727            ? $this->displayDate((string)$dateDue[0]) : null;
728
729        $isHoldable = $this->isItemHoldable($current);
730        // Build return array:
731        $return = [
732            'id' => $aggregateId,
733            'availability' =>  $this->isAvailable($status),
734            'status' => $status,
735            'item_id' => $itemId,
736            'bib_id' => $bibId,
737            'item_agency_id' => $itemAgencyId,
738            'location' => $location,
739            'reserve' => 'N',       // not supported
740            'callnumber' => $itemCallNo,
741            'duedate' => $dateDue,
742            'volume' => $volume,
743            'number' => $number,
744            'barcode' => ($itemType === 'Barcode')
745                ? $itemId : 'Unknown barcode',
746            'is_holdable'  => $isHoldable,
747            'addLink' => $this->isPatronBlocked($patron) ? false : $isHoldable,
748            'holdtype' => $this->getHoldType($status),
749            'storageRetrievalRequest' => 'auto',
750            'addStorageRetrievalRequestLink' => 'true',
751        ];
752        if (strtolower($status) === 'circulation status undefined') {
753            $return['use_unknown_message'] = true;
754        }
755        if (!empty($collection)) {
756            $return['collection_desc'] = $collection;
757        }
758
759        return $return;
760    }
761
762    /**
763     * Get Status
764     *
765     * This is responsible for retrieving the status information of a certain
766     * record.
767     *
768     * @param string $id The record id to retrieve the holdings for
769     *
770     * @throws ILSException
771     * @return mixed     On success, an associative array with the following keys:
772     * id, availability (boolean), status, location, reserve, callnumber.
773     */
774    public function getStatus($id)
775    {
776        // For now, we'll just use getHolding, since getStatus should return a
777        // subset of the same fields, and the extra values will be ignored.
778        return $this->getHolding($id);
779    }
780
781    /**
782     * Build NCIP2 request XML for item status information.
783     *
784     * @param array  $idList     IDs to look up.
785     * @param string $resumption Resumption token (null for first page of set).
786     * @param string $agency     Agency ID.
787     *
788     * @return string            XML request
789     */
790    protected function getStatusRequest($idList, $resumption = null, $agency = null)
791    {
792        $agency = $this->determineToAgencyId($agency);
793
794        // Build a list of the types of information we want to retrieve:
795        $desiredParts = [
796            'Bibliographic Description',
797            'Circulation Status',
798            'Electronic Resource',
799            'Hold Queue Length',
800            'Item Description',
801            'Item Use Restriction Type',
802            'Location',
803        ];
804
805        // Start the XML:
806        $xml = $this->getNCIPMessageStart() . '<ns1:LookupItemSet>';
807        $xml .= $this->getInitiationHeaderXml($agency);
808
809        foreach ($idList as $id) {
810            $xml .= $this->getBibliographicId($id);
811        }
812
813        // Add the desired data list:
814        foreach ($desiredParts as $current) {
815            $xml .= $this->element('ItemElementType', $current);
816        }
817
818        // Add resumption token if necessary:
819        if (!empty($resumption)) {
820            $xml .= $this->element('NextItemToken', $resumption);
821        }
822
823        // Close the XML and send it to the caller:
824        $xml .= '</ns1:LookupItemSet></ns1:NCIPMessage>';
825        return $xml;
826    }
827
828    /**
829     * Get Statuses
830     *
831     * This is responsible for retrieving the status information for a
832     * collection of records.
833     *
834     * @param array $idList The array of record ids to retrieve the status for
835     *
836     * @throws ILSException
837     * @return array        An array of getStatus() return values on success.
838     */
839    public function getStatuses($idList)
840    {
841        $status = [];
842
843        if ($this->consortium) {
844            return $status; // (empty) TODO: add support for consortial statuses.
845        }
846        $resumption = null;
847        do {
848            $request = $this->getStatusRequest($idList, $resumption);
849            $response = $this->sendRequest($request);
850            $bibInfo = $response->xpath(
851                'ns1:LookupItemSetResponse/ns1:BibInformation'
852            );
853
854            // Build the array of statuses:
855            foreach ($bibInfo as $bib) {
856                $this->registerNamespaceFor($bib);
857                $bibId = $bib->xpath(
858                    'ns1:BibliographicId/ns1:BibliographicRecordId/' .
859                    'ns1:BibliographicRecordIdentifier' .
860                    ' | ' .
861                    'ns1:BibliographicId/ns1:BibliographicItemId/' .
862                    'ns1:BibliographicItemIdentifier'
863                );
864                if (empty($bibId)) {
865                    throw new ILSException(
866                        'Bibliographic record/item identifier missing in lookup ' .
867                        'item set response'
868                    );
869                }
870                $bibId = (string)$bibId[0];
871
872                $holdings = $bib->xpath('ns1:HoldingsSet');
873
874                foreach ($holdings as $holding) {
875                    $this->registerNamespaceFor($holding);
876                    $holdCallNo = $holding->xpath('ns1:CallNumber');
877                    $holdCallNo = !empty($holdCallNo) ? (string)$holdCallNo[0]
878                        : null;
879
880                    $items = $holding->xpath('ns1:ItemInformation');
881
882                    $holdingLocation = $holding->xpath(
883                        'ns1:Location/ns1:LocationName/ns1:LocationNameInstance/' .
884                        'ns1:LocationNameValue'
885                    );
886                    $holdingLocation = !empty($holdingLocation)
887                        ? (string)$holdingLocation[0] : null;
888
889                    foreach ($items as $item) {
890                        // Get data on the current chunk of data:
891                        $chunk = $this->getStatusForChunk($item);
892
893                        $chunk['callnumber'] = empty($chunk['callnumber']) ?
894                            $holdCallNo : $chunk['callnumber'];
895
896                        // Each bibliographic ID has its own key in the $status
897                        // array; make sure we initialize new arrays when necessary
898                        // and then add the current chunk to the right place:
899                        $chunk['id'] = $bibId;
900                        if (!isset($status[$bibId])) {
901                            $status[$bibId] = [];
902                        }
903                        $chunk['location'] ??= $holdingLocation ?? null;
904                        $status[$bibId][] = $chunk;
905                    }
906                }
907            }
908
909            // Check for resumption token:
910            $resumption = $response->xpath(
911                'ns1:LookupItemSetResponse/ns1:NextItemToken'
912            );
913            $resumption = count($resumption) > 0 ? (string)$resumption[0] : null;
914        } while (!empty($resumption));
915        return $status;
916    }
917
918    /**
919     * Get Consortial Holding
920     *
921     * This is responsible for retrieving the holding information of a certain
922     * consortial record.
923     *
924     * @param string $id     The record id to retrieve the holdings for
925     * @param array  $patron Patron data
926     * @param array  $ids    The (consortial) source records for the record id
927     *
928     * @throws DateException
929     * @throws ILSException
930     * @return array         On success, an associative array with the following
931     * keys: id, availability (boolean), status, location, reserve, callnumber,
932     * duedate, number, barcode.
933     *
934     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
935     */
936    public function getConsortialHoldings(
937        $id,
938        array $patron = null,
939        array $ids = null
940    ) {
941        $aggregateId = $id;
942
943        $agencyList = [];
944        $idList = [];
945        if (null !== $ids) {
946            foreach ($ids as $id) {
947                // Need to parse out the 035$a format, e.g., "(Agency) 123"
948                if (preg_match('/\(([^\)]+)\)\s*(.+)/', $id, $matches)) {
949                    $matchedAgency = $matches[1];
950                    $matchedId = $matches[2];
951                    if (array_key_exists($matchedAgency, $this->agency)) {
952                        $agencyList[] = $matchedAgency;
953                        $idList[] = $matchedId;
954                    }
955                }
956            }
957        }
958
959        $holdings = [];
960        $bibs = $this->getBibs($idList, $agencyList);
961
962        foreach ($bibs as $bib) {
963            $this->registerNamespaceFor($bib);
964            $bibIds = $bib->xpath(
965                'ns1:BibliographicId/ns1:BibliographicRecordId/' .
966                'ns1:BibliographicRecordIdentifier' .
967                ' | ' .
968                'ns1:BibliographicId/ns1:BibliographicItemId/' .
969                'ns1:BibliographicItemIdentifier'
970            );
971            $bibId = (string)$bibIds[0];
972
973            $holdingSets = $bib->xpath('ns1:HoldingsSet');
974            foreach ($holdingSets as $holding) {
975                $this->registerNamespaceFor($holding);
976                $holdCallNo = $holding->xpath('ns1:CallNumber');
977                $holdCallNo = (string)($holdCallNo[0] ?? '');
978                $avail = $holding->xpath('ns1:ItemInformation');
979                $eResource = $holding->xpath(
980                    'ns1:ElectronicResource/ns1:ReferenceToResource'
981                );
982                $eResource = (string)($eResource[0] ?? '');
983
984                $locations = $holding->xpath(
985                    'ns1:Location/ns1:LocationName/ns1:LocationNameInstance'
986                );
987                [$holdingLocation, $collection]
988                    = $this->parseLocationInstance($locations);
989
990                // Build the array of holdings:
991                foreach ($avail as $current) {
992                    $chunk = $this->getHoldingsForChunk(
993                        $current,
994                        $aggregateId,
995                        $bibId,
996                        $patron
997                    );
998                    $chunk['callnumber'] = empty($chunk['callnumber']) ?
999                        $holdCallNo : $chunk['callnumber'];
1000                    $chunk['eresource'] = $eResource;
1001                    $chunk['location'] ??= $holdingLocation ?? null;
1002                    if (
1003                        !isset($chunk['collection_desc'])
1004                        && !empty($collection)
1005                    ) {
1006                        $chunk['collection_desc'] = $collection;
1007                    }
1008                    $holdings[] = $chunk;
1009                }
1010            }
1011        }
1012
1013        return $holdings;
1014    }
1015
1016    /**
1017     * Get Holding
1018     *
1019     * This is responsible for retrieving the holding information of a certain
1020     * record.
1021     *
1022     * @param string $id      The record id to retrieve the holdings for
1023     * @param array  $patron  Patron data
1024     * @param array  $options Extra options (not currently used)
1025     *
1026     * @throws DateException
1027     * @throws ILSException
1028     * @return array         On success, an associative array with the following
1029     * keys: id, availability (boolean), status, location, reserve, callnumber,
1030     * duedate, number, barcode.
1031     *
1032     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1033     */
1034    public function getHolding($id, array $patron = null, array $options = [])
1035    {
1036        $ids = null;
1037        if (! $this->consortium) {
1038            // Translate $id into consortial (035$a) format,
1039            // e.g., "123" -> "(Agency) 123"
1040            $sourceRecord = '';
1041            foreach (array_keys($this->agency) as $Agency) {
1042                $sourceRecord = '(' . $Agency . ') ';
1043            }
1044            $sourceRecord .= $id;
1045            $ids = [$sourceRecord];
1046        }
1047
1048        return $this->getConsortialHoldings($id, $patron, $ids);
1049    }
1050
1051    /**
1052     * Get Purchase History
1053     *
1054     * This is responsible for retrieving the acquisitions history data for the
1055     * specific record (usually recently received issues of a serial).
1056     *
1057     * @param string $id The record id to retrieve the info for
1058     *
1059     * @throws ILSException
1060     * @return array     An array with the acquisitions data on success.
1061     *
1062     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1063     */
1064    public function getPurchaseHistory($id)
1065    {
1066        // NCIP is not able to send acquisition data
1067        return [];
1068    }
1069
1070    /**
1071     * Patron Login
1072     *
1073     * This is responsible for authenticating a patron against the catalog.
1074     *
1075     * @param string $username The patron username
1076     * @param string $password The patron's password
1077     *
1078     * @throws ILSException
1079     * @return mixed          Associative array of patron info on successful login,
1080     * null on unsuccessful login.
1081     */
1082    public function patronLogin($username, $password)
1083    {
1084        $response = $this->getLookupUserResponse($username, $password);
1085
1086        $id = $response->xpath(
1087            'ns1:LookupUserResponse/ns1:UserId/ns1:UserIdentifierValue'
1088        );
1089        if (empty($id)) {
1090            return null;
1091        }
1092
1093        $patronAgencyId = $response->xpath(
1094            'ns1:LookupUserResponse/ns1:UserId/ns1:AgencyId'
1095        );
1096        $first = $response->xpath(
1097            'ns1:LookupUserResponse/ns1:UserOptionalFields/ns1:NameInformation/' .
1098            'ns1:PersonalNameInformation/ns1:StructuredPersonalUserName/' .
1099            'ns1:GivenName'
1100        );
1101        $last = $response->xpath(
1102            'ns1:LookupUserResponse/ns1:UserOptionalFields/ns1:NameInformation/' .
1103            'ns1:PersonalNameInformation/ns1:StructuredPersonalUserName/' .
1104            'ns1:Surname'
1105        );
1106        $email = $response->xpath(
1107            'ns1:LookupUserResponse/ns1:UserOptionalFields/' .
1108            'ns1:UserAddressInformation/ns1:ElectronicAddress/' .
1109                'ns1:ElectronicAddressData'
1110        );
1111
1112        // Fill in basic patron details:
1113        return [
1114            'id' => (string)$id[0],
1115            'patronAgencyId' => !empty($patronAgencyId)
1116                ? (string)$patronAgencyId[0] : null,
1117            'cat_username' => $username,
1118            'cat_password' => $password,
1119            'email' => !empty($email) ? (string)$email[0] : null,
1120            'major' => null,
1121            'college' => null,
1122            'firstname' => (string)($first[0] ?? ''),
1123            'lastname' => (string)($last[0] ?? ''),
1124        ];
1125    }
1126
1127    /**
1128     * Get Patron Transactions
1129     *
1130     * This is responsible for retrieving all transactions (i.e. checked out items)
1131     * by a specific patron.
1132     *
1133     * @param array $patron The patron array from patronLogin
1134     *
1135     * @throws DateException
1136     * @throws ILSException
1137     * @return array        Array of the patron's transactions on success.
1138     */
1139    public function getMyTransactions($patron)
1140    {
1141        $response = $this->getLookupUserResponse(
1142            $patron['cat_username'],
1143            $patron['cat_password']
1144        );
1145
1146        $retVal = [];
1147        $list = $response->xpath('ns1:LookupUserResponse/ns1:LoanedItem');
1148        foreach ($list as $current) {
1149            $this->registerNamespaceFor($current);
1150            $tmp = $current->xpath('ns1:DateDue');
1151            // DateDue could be omitted in response
1152            $due = $this->displayDate(!empty($tmp) ? (string)$tmp[0] : null);
1153            $title = $current->xpath('ns1:Title');
1154            $itemId = $current->xpath('ns1:ItemId/ns1:ItemIdentifierValue');
1155            $itemId = (string)$itemId[0];
1156            $bibId = $current->xpath(
1157                'ns1:Ext/ns1:BibliographicDescription/' .
1158                'ns1:BibliographicRecordId/ns1:BibliographicRecordIdentifier' .
1159                ' | ' .
1160                'ns1:Ext/ns1:BibliographicDescription/' .
1161                'ns1:BibliographicItemId/ns1:BibliographicItemIdentifier'
1162            );
1163            $itemAgencyId = $current->xpath(
1164                'ns1:Ext/ns1:BibliographicDescription/' .
1165                'ns1:BibliographicRecordId/ns1:AgencyId' .
1166                ' | ' .
1167                'ns1:ItemId/ns1:AgencyId'
1168            );
1169
1170            $renewable = $this->disableRenewals
1171                ? false
1172                : empty($current->xpath('ns1:Ext/ns1:RenewalNotPermitted'));
1173
1174            $itemAgencyId = !empty($itemAgencyId) ? (string)$itemAgencyId[0] : null;
1175            $bibId = !empty($bibId) ? (string)$bibId[0] : null;
1176            if ($bibId === null || $itemAgencyId === null || empty($due)) {
1177                $itemType = $current->xpath('ns1:ItemId/ns1:ItemIdentifierType');
1178                $itemType = !empty($itemType) ? (string)$itemType[0] : null;
1179                $itemRequest = $this->getLookupItemRequest($itemId, $itemType);
1180                $itemResponse = $this->sendRequest($itemRequest);
1181            }
1182            if ($bibId === null && isset($itemResponse)) {
1183                $bibId = $itemResponse->xpath(
1184                    'ns1:LookupItemResponse/ns1:ItemOptionalFields/' .
1185                    'ns1:BibliographicDescription/ns1:BibliographicItemId/' .
1186                    'ns1:BibliographicItemIdentifier' .
1187                    ' | ' .
1188                    'ns1:LookupItemResponse/ns1:ItemOptionalFields/' .
1189                    'ns1:BibliographicDescription/ns1:BibliographicRecordId/' .
1190                    'ns1:BibliographicRecordIdentifier'
1191                );
1192                // Hack to account for bibs from other non-local institutions
1193                // temporarily until consortial functionality is enabled.
1194                $bibId = !empty($bibId) ? (string)$bibId[0] : '1';
1195            }
1196            if ($itemAgencyId === null && isset($itemResponse)) {
1197                $itemAgencyId = $itemResponse->xpath(
1198                    'ns1:LookupItemResponse/ns1:ItemOptionalFields/' .
1199                    'ns1:BibliographicDescription/ns1:BibliographicRecordId/' .
1200                    'ns1:AgencyId' .
1201                    ' | ' .
1202                    'ns1:LookupItemResponse/ns1:ItemId/ns1:AgencyId'
1203                );
1204                $itemAgencyId = !empty($itemAgencyId)
1205                    ? (string)$itemAgencyId[0] : null;
1206            }
1207            if (empty($due) && isset($itemResponse)) {
1208                $rawDueDate = $itemResponse->xpath(
1209                    'ns1:LookupItemResponse/ns1:ItemOptionalFields/' .
1210                    'ns1:DateDue'
1211                );
1212                $due = $this->displayDate(
1213                    !empty($rawDueDate) ? (string)$rawDueDate[0] : null
1214                );
1215            }
1216
1217            $retVal[] = [
1218                'id' => $bibId,
1219                'item_agency_id' => $itemAgencyId,
1220                'patronAgencyId' => $patron['patronAgencyId'],
1221                'duedate' => $due,
1222                'title' => !empty($title) ? (string)$title[0] : null,
1223                'item_id' => $itemId,
1224                'renewable' => $renewable,
1225            ];
1226        }
1227
1228        return $retVal;
1229    }
1230
1231    /**
1232     * Get Patron Fines
1233     *
1234     * This is responsible for retrieving all fines by a specific patron.
1235     *
1236     * @param array $patron The patron array from patronLogin
1237     *
1238     * @throws DateException
1239     * @throws ILSException
1240     * @return mixed        Array of the patron's fines on success.
1241     */
1242    public function getMyFines($patron)
1243    {
1244        $response = $this->getLookupUserResponse(
1245            $patron['cat_username'],
1246            $patron['cat_password']
1247        );
1248
1249        $list = $response->xpath(
1250            'ns1:LookupUserResponse/ns1:UserFiscalAccount/ns1:AccountDetails'
1251        );
1252
1253        $fines = [];
1254        foreach ($list as $current) {
1255            $this->registerNamespaceFor($current);
1256
1257            $amount = $current->xpath(
1258                'ns1:FiscalTransactionInformation/ns1:Amount/ns1:MonetaryValue'
1259            );
1260            $amount = (string)($amount[0] ?? '');
1261            $date = $current->xpath('ns1:AccrualDate');
1262            $date = $this->displayDate(!empty($date) ? (string)$date[0] : null);
1263            $desc = $current->xpath(
1264                'ns1:FiscalTransactionInformation/ns1:FiscalTransactionType'
1265            );
1266            $desc = (string)($desc[0] ?? '');
1267
1268            $bibId = $current->xpath(
1269                'ns1:FiscalTransactionInformation/ns1:ItemDetails/' .
1270                'ns1:BibliographicDescription/ns1:BibliographicRecordId/' .
1271                'ns1:BibliographicRecordIdentifier' .
1272                ' | ' .
1273                'ns1:FiscalTransactionInformation/ns1:ItemDetails/' .
1274                'ns1:BibliographicDescription/ns1:BibliographicItemId/' .
1275                'ns1:BibliographicItemIdentifier'
1276            );
1277            $id = (string)($bibId[0] ?? '');
1278            $fines[] = [
1279                'amount' => $amount,
1280                'balance' => $amount,
1281                'checkout' => '',
1282                'fine' => $desc,
1283                'duedate' => '',
1284                'createdate' => $date,
1285                'id' => $id,
1286            ];
1287        }
1288        return $fines;
1289    }
1290
1291    /**
1292     * Get Patron requests by type
1293     *
1294     * This is responsible for retrieving all holds by a specific patron.
1295     *
1296     * @param array $patron The patron array from patronLogin
1297     * @param array $types  Request types
1298     *
1299     * @throws DateException
1300     * @throws ILSException
1301     * @return array        Array of the patron's holds on success.
1302     */
1303    protected function getMyRequests(array $patron, array $types)
1304    {
1305        $response = $this->getLookupUserResponse(
1306            $patron['cat_username'],
1307            $patron['cat_password']
1308        );
1309
1310        $retVal = [];
1311        $requests = $response->xpath('ns1:LookupUserResponse/ns1:RequestedItem');
1312
1313        foreach ($requests as $current) {
1314            $this->registerNamespaceFor($current);
1315            $id = $current->xpath(
1316                'ns1:Ext/ns1:BibliographicDescription/' .
1317                'ns1:BibliographicRecordId/ns1:BibliographicRecordIdentifier' .
1318                ' | ' .
1319                'ns1:Ext/ns1:BibliographicDescription/' .
1320                'ns1:BibliographicItemId/ns1:BibliographicItemIdentifier' .
1321                ' | ' .
1322                'ns1:BibliographicId/' .
1323                'ns1:BibliographicRecordId/ns1:BibliographicRecordIdentifier' .
1324                ' | ' .
1325                'ns1:BibliographicId/' .
1326                'ns1:BibliographicItemId/ns1:BibliographicItemIdentifier'
1327            );
1328            $itemAgencyId = $current->xpath(
1329                'ns1:Ext/ns1:BibliographicDescription/' .
1330                'ns1:BibliographicRecordId/ns1:AgencyId'
1331            );
1332
1333            $title = $current->xpath('ns1:Title');
1334            $pos = $current->xpath('ns1:HoldQueuePosition');
1335            $requestId = $current->xpath('ns1:RequestId/ns1:RequestIdentifierValue');
1336            $itemId = $current->xpath('ns1:ItemId/ns1:ItemIdentifierValue');
1337            $pickupLocation = $current->xpath('ns1:PickupLocation');
1338            $created = $current->xpath('ns1:DatePlaced');
1339            $created = $this->displayDate(
1340                !empty($created) ? (string)$created[0] : null
1341            );
1342            $expireDate = $current->xpath('ns1:PickupExpiryDate');
1343            $expireDate = $this->displayDate(
1344                !empty($expireDate) ? (string)$expireDate[0] : null
1345            );
1346
1347            $requestStatusType = $current->xpath('ns1:RequestStatusType');
1348            $status = !empty($requestStatusType) ? (string)$requestStatusType[0]
1349                : null;
1350            $available = strtolower($status) === $this->requestAvailableStatus;
1351
1352            // Only return requests of desired type
1353            if ($this->checkRequestType($current, $types)) {
1354                $retVal[] = [
1355                    'id' => (string)($id[0] ?? ''),
1356                    'create' => $created,
1357                    'expire' => $expireDate,
1358                    'title' => !empty($title) ? (string)$title[0] : null,
1359                    'position' => !empty($pos) ? (string)$pos[0] : null,
1360                    'requestId' => !empty($requestId) ? (string)$requestId[0] : null,
1361                    'item_agency_id' => !empty($itemAgencyId)
1362                        ? (string)$itemAgencyId[0] : null,
1363                    'canceled' => $this->isRequestCancelled($status),
1364                    'item_id' => !empty($itemId[0]) ? (string)$itemId[0] : null,
1365                    'location' => !empty($pickupLocation[0])
1366                        ? (string)$pickupLocation[0] : null,
1367                    'available' => $available,
1368                ];
1369            }
1370        }
1371        return $retVal;
1372    }
1373
1374    /**
1375     * Get Patron Holds
1376     *
1377     * This is responsible for retrieving all holds by a specific patron.
1378     *
1379     * @param array $patron The patron array from patronLogin
1380     *
1381     * @throws DateException
1382     * @throws ILSException
1383     * @return array        Array of the patron's holds on success.
1384     */
1385    public function getMyHolds($patron)
1386    {
1387        return $this->getMyRequests($patron, $this->holdRequestTypes);
1388    }
1389
1390    /**
1391     * Get Patron Profile
1392     *
1393     * This is responsible for retrieving the profile for a specific patron.
1394     *
1395     * @param array $patron The patron array
1396     *
1397     * @throws ILSException
1398     * @return array        Array of the patron's profile data on success.
1399     */
1400    public function getMyProfile($patron)
1401    {
1402        $response = $this->getLookupUserResponse(
1403            $patron['cat_username'],
1404            $patron['cat_password']
1405        );
1406
1407        $firstname = $response->xpath(
1408            'ns1:LookupUserResponse/ns1:UserOptionalFields/ns1:NameInformation/' .
1409            'ns1:PersonalNameInformation/ns1:StructuredPersonalUserName/' .
1410            'ns1:GivenName'
1411        );
1412        $lastname = $response->xpath(
1413            'ns1:LookupUserResponse/ns1:UserOptionalFields/ns1:NameInformation/' .
1414            'ns1:PersonalNameInformation/ns1:StructuredPersonalUserName/' .
1415            'ns1:Surname'
1416        );
1417        if (empty($firstname) && empty($lastname)) {
1418            $lastname = $response->xpath(
1419                'ns1:LookupUserResponse/ns1:UserOptionalFields/' .
1420                'ns1:NameInformation/ns1:PersonalNameInformation/' .
1421                'ns1:UnstructuredPersonalUserName'
1422            );
1423        }
1424
1425        $address1 = $response->xpath(
1426            'ns1:LookupUserResponse/ns1:UserOptionalFields/' .
1427            'ns1:UserAddressInformation/ns1:PhysicalAddress/' .
1428            'ns1:StructuredAddress/ns1:Line1' .
1429            '|' .
1430            'ns1:LookupUserResponse/ns1:UserOptionalFields/' .
1431            'ns1:UserAddressInformation/ns1:PhysicalAddress/' .
1432            'ns1:StructuredAddress/ns1:Street'
1433        );
1434        $address1 = !empty($address1) ? (string)$address1[0] : null;
1435        $address2 = $response->xpath(
1436            'ns1:LookupUserResponse/ns1:UserOptionalFields/' .
1437            'ns1:UserAddressInformation/ns1:PhysicalAddress/' .
1438            'ns1:StructuredAddress/ns1:Line2' .
1439            '|' .
1440            'ns1:LookupUserResponse/ns1:UserOptionalFields/' .
1441            'ns1:UserAddressInformation/ns1:PhysicalAddress/' .
1442            'ns1:StructuredAddress/ns1:Locality'
1443        );
1444        $address2 = !empty($address2) ? (string)$address2[0] : null;
1445        $zip = $response->xpath(
1446            'ns1:LookupUserResponse/ns1:UserOptionalFields/' .
1447            'ns1:UserAddressInformation/ns1:PhysicalAddress/' .
1448            'ns1:StructuredAddress/ns1:PostalCode'
1449        );
1450        $zip = !empty($zip) ? (string)$zip[0] : null;
1451
1452        if (empty($address1)) {
1453            // TODO: distinguish between more formatting types; look
1454            // at the UnstructuredAddressType field and handle multiple options.
1455            $address = $response->xpath(
1456                'ns1:LookupUserResponse/ns1:UserOptionalFields/' .
1457                'ns1:UserAddressInformation/ns1:PhysicalAddress/' .
1458                'ns1:UnstructuredAddress/ns1:UnstructuredAddressData'
1459            );
1460            $address = explode("\n", trim((string)($address[0] ?? '')));
1461            $address1 = $address[0] ?? null;
1462            $address2 = ($address[1] ?? null);
1463            if (isset($address[2])) {
1464                $address2 .= ', ' . $address[2];
1465            }
1466            $zip ??= $address[3] ?? null;
1467        }
1468
1469        $expirationDate = $response->xpath(
1470            'ns1:LookupUserResponse/ns1:UserOptionalFields/ns1:UserPrivilege/' .
1471            'ns1:ValidToDate'
1472        );
1473        $expirationDate = !empty($expirationDate) ?
1474            $this->displayDate((string)$expirationDate[0]) : null;
1475
1476        return [
1477            'firstname' => (string)($firstname[0] ?? null),
1478            'lastname' => (string)($lastname[0] ?? null),
1479            'address1' => $address1,
1480            'address2' => $address2,
1481            'zip' => $zip,
1482            'phone' => null,  // TODO: phone number support
1483            'group' => null,
1484            'expiration_date' => $expirationDate,
1485        ];
1486    }
1487
1488    /**
1489     * Get New Items
1490     *
1491     * Retrieve the IDs of items recently added to the catalog.
1492     *
1493     * @param int $page    Page number of results to retrieve (counting starts at 1)
1494     * @param int $limit   The size of each page of results to retrieve
1495     * @param int $daysOld The maximum age of records to retrieve in days (max. 30)
1496     * @param int $fundId  optional fund ID to use for limiting results (use a value
1497     * returned by getFunds, or exclude for no limit); note that "fund" may be a
1498     * misnomer - if funds are not an appropriate way to limit your new item
1499     * results, you can return a different set of values from getFunds. The
1500     * important thing is that this parameter supports an ID returned by getFunds,
1501     * whatever that may mean.
1502     *
1503     * @throws ILSException
1504     * @return array       Associative array with 'count' and 'results' keys
1505     *
1506     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1507     */
1508    public function getNewItems($page, $limit, $daysOld, $fundId = null)
1509    {
1510        // NCIP is not able to send acquisition data
1511        return [];
1512    }
1513
1514    /**
1515     * Get Funds
1516     *
1517     * Return a list of funds which may be used to limit the getNewItems list.
1518     *
1519     * @throws ILSException
1520     * @return array An associative array with key = fund ID, value = fund name.
1521     */
1522    public function getFunds()
1523    {
1524        // NCIP is not able to send acquisition data, so we don't need getFunds
1525        return [];
1526    }
1527
1528    /**
1529     * Get Departments
1530     *
1531     * Obtain a list of departments for use in limiting the reserves list.
1532     *
1533     * @throws ILSException
1534     * @return array An associative array with key = dept. ID, value = dept. name.
1535     */
1536    public function getDepartments()
1537    {
1538        // NCIP does not support course reserves
1539        return [];
1540    }
1541
1542    /**
1543     * Get Instructors
1544     *
1545     * Obtain a list of instructors for use in limiting the reserves list.
1546     *
1547     * @throws ILSException
1548     * @return array An associative array with key = ID, value = name.
1549     */
1550    public function getInstructors()
1551    {
1552        // NCIP does not support course reserves
1553        return [];
1554    }
1555
1556    /**
1557     * Get Courses
1558     *
1559     * Obtain a list of courses for use in limiting the reserves list.
1560     *
1561     * @throws ILSException
1562     * @return array An associative array with key = ID, value = name.
1563     */
1564    public function getCourses()
1565    {
1566        // NCIP does not support course reserves
1567        return [];
1568    }
1569
1570    /**
1571     * Find Reserves
1572     *
1573     * Obtain information on course reserves.
1574     *
1575     * @param string $course ID from getCourses (empty string to match all)
1576     * @param string $inst   ID from getInstructors (empty string to match all)
1577     * @param string $dept   ID from getDepartments (empty string to match all)
1578     *
1579     * @throws ILSException
1580     * @return array An array of associative arrays representing reserve items.
1581     *
1582     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1583     */
1584    public function findReserves($course, $inst, $dept)
1585    {
1586        // NCIP does not support course reserves
1587        return [];
1588    }
1589
1590    /**
1591     * Get suppressed records.
1592     *
1593     * @throws ILSException
1594     * @return array ID numbers of suppressed records in the system.
1595     */
1596    public function getSuppressedRecords()
1597    {
1598        // NCIP does not support this
1599        return [];
1600    }
1601
1602    /**
1603     * Public Function which retrieves Holds, StorageRetrievalRequests, and
1604     * Consortial settings from the driver ini file.
1605     *
1606     * @param string $function The name of the feature to be checked
1607     * @param array  $params   Optional feature-specific parameters (array)
1608     *
1609     * @return array An array with key-value pairs.
1610     *
1611     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1612     */
1613    public function getConfig($function, $params = [])
1614    {
1615        if ($function == 'Holds') {
1616            $holdsConfig = $this->config['Holds'] ?? [];
1617            $extraHoldFields = empty($this->getPickUpLocations(null))
1618                ? 'comments:requiredByDate'
1619                : 'comments:pickUpLocation:requiredByDate';
1620            $defaults =  [
1621                'HMACKeys' => 'item_id:holdtype:item_agency_id:id:bib_id',
1622                'extraHoldFields' => $extraHoldFields,
1623                'defaultRequiredDate' => '0:2:0',
1624                'consortium' => $this->consortium,
1625            ];
1626            return $holdsConfig + $defaults;
1627        }
1628        if ($function == 'StorageRetrievalRequests') {
1629            $config = $this->config['StorageRetrievalRequests'] ?? [];
1630            $extraFields = empty($this->getPickUpLocations(null))
1631                ? 'comments:requiredByDate:item-issue'
1632                : 'comments:pickUpLocation:requiredByDate:item-issue';
1633            $defaults =  [
1634                'HMACKeys' => 'id:item_id:item_agency_id:id:bib_id',
1635                'extraFields' => $extraFields,
1636                'defaultRequiredDate' => '0:2:0',
1637            ];
1638            return $config + $defaults;
1639        }
1640        return [];
1641    }
1642
1643    /**
1644     * Get Default Pick Up Location
1645     *
1646     * Returns the default pick up location set in HorizonXMLAPI.ini
1647     *
1648     * @param array $patron      Patron information returned by the patronLogin
1649     * method.
1650     * @param array $holdDetails Optional array, only passed in when getting a list
1651     * in the context of placing a hold; contains most of the same values passed to
1652     * placeHold, minus the patron data. May be used to limit the pickup options
1653     * or may be ignored.
1654     *
1655     * @return string A location ID
1656     *
1657     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1658     */
1659    public function getDefaultPickUpLocation($patron, $holdDetails = null)
1660    {
1661        return $this->pickupLocations[$patron['patronAgencyId']][0]['locationID'];
1662    }
1663
1664    /**
1665     * Return patron blocks
1666     *
1667     * @param array $patron Patron data from patronLogin method
1668     *
1669     * @return array
1670     * @throws ILSException
1671     */
1672    protected function getPatronBlocks($patron = null): ?array
1673    {
1674        if (empty($patron)) {
1675            return [];
1676        }
1677        $response = $this->getLookupUserResponse($patron['cat_username']);
1678        $blocks = $response->xpath(
1679            'ns1:LookupUserResponse/ns1:UserOptionalFields/ns1:BlockOrTrap/' .
1680            'ns1:BlockOrTrapType'
1681        );
1682        $blocks = ($blocks === false) ? [] : $blocks;
1683        return array_map(
1684            function ($block) {
1685                return (string)$block;
1686            },
1687            $blocks
1688        );
1689    }
1690
1691    /**
1692     * Helper function to distinguish if blocks are really blocking patron from
1693     * actions on ILS, or if they are more like notifies
1694     *
1695     * @param array $patron Patron from patronLogin
1696     *
1697     * @return bool
1698     * @throws ILSException
1699     */
1700    protected function isPatronBlocked(?array $patron): bool
1701    {
1702        $blocks = $this->getPatronBlocks($patron);
1703        $blocks = array_filter(
1704            $blocks,
1705            function ($item) {
1706                return str_starts_with($item, 'Block');
1707            }
1708        );
1709        return !empty($blocks);
1710    }
1711
1712    /**
1713     * Get Pick Up Locations
1714     *
1715     * This is responsible get a list of valid library locations for holds / recall
1716     * retrieval
1717     *
1718     * @param array $patron      Patron information returned by the patronLogin
1719     * method.
1720     * @param array $holdDetails Optional array, only passed in when getting a list
1721     * in the context of placing or editing a hold. When placing a hold, it contains
1722     * most of the same values passed to placeHold, minus the patron data. When
1723     * editing a hold it contains all the hold information returned by getMyHolds.
1724     * May be used to limit the pickup options or may be ignored. The driver must
1725     * not add new options to the return array based on this data or other areas of
1726     * VuFind may behave incorrectly.
1727     *
1728     * @return array        An array of associative arrays with locationID and
1729     * locationDisplay keys
1730     *
1731     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1732     */
1733    public function getPickUpLocations($patron, $holdDetails = null)
1734    {
1735        if (!isset($this->pickupLocations)) {
1736            $this->loadPickUpLocations();
1737        }
1738        return array_values($this->pickupLocations);
1739    }
1740
1741    /**
1742     * Get Patron Storage Retrieval Requests
1743     *
1744     * This is responsible for retrieving all call slips by a specific patron.
1745     *
1746     * @param array $patron The patron array from patronLogin
1747     *
1748     * @return array        Array of the patron's storage retrieval requests.
1749     */
1750    public function getMyStorageRetrievalRequests($patron)
1751    {
1752        return $this->getMyRequests($patron, $this->storageRetrievalRequestTypes);
1753    }
1754
1755    /**
1756     * Check if storage retrieval request available
1757     *
1758     * This is responsible for determining if an item is requestable
1759     *
1760     * @param string $id     The Bib ID
1761     * @param array  $data   An Array of item data
1762     * @param array  $patron An array of patron data
1763     *
1764     * @return bool True if request is valid, false if not
1765     *
1766     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1767     */
1768    public function checkStorageRetrievalRequestIsValid($id, $data, $patron)
1769    {
1770        return true;
1771    }
1772
1773    /**
1774     * Place Storage Retrieval Request (Call Slip)
1775     *
1776     * Attempts to place a call slip request on a particular item and returns
1777     * an array with result details
1778     *
1779     * @param array $details An array of item and patron data
1780     *
1781     * @return mixed An array of data on the request including
1782     * whether or not it was successful.
1783     */
1784    public function placeStorageRetrievalRequest($details)
1785    {
1786        return $this->placeRequest($details, 'Stack Retrieval');
1787    }
1788
1789    /**
1790     * Get Renew Details
1791     *
1792     * This function returns the item id as a string which is then used
1793     * as submitted form data in checkedOut.php. This value is then extracted by
1794     * the RenewMyItems function.
1795     *
1796     * @param array $checkOutDetails An array of item data
1797     *
1798     * @return string Data for use in a form field
1799     */
1800    public function getRenewDetails($checkOutDetails)
1801    {
1802        return $checkOutDetails['item_agency_id'] .
1803            '|' . $checkOutDetails['item_id'];
1804    }
1805
1806    /**
1807     * Place Hold
1808     *
1809     * Attempts to place a hold or recall on a particular item and returns
1810     * an array with result details or throws an exception on failure of support
1811     * classes
1812     *
1813     * @param array $details An array of item and patron data
1814     *
1815     * @throws ILSException
1816     * @return mixed An array of data on the request including
1817     * whether or not it was successful
1818     */
1819    public function placeHold($details)
1820    {
1821        return $this->placeRequest($details, $details['holdtype']);
1822    }
1823
1824    /**
1825     * Place a general request
1826     *
1827     * Attempts to place a hold or recall on a particular item and returns
1828     * an array with result details or throws an exception on failure of support
1829     * classes
1830     *
1831     * @param array  $details An array of item and patron data
1832     * @param string $type    Type of request, could be 'Hold' or 'Stack Retrieval'
1833     *
1834     * @throws ILSException
1835     * @return mixed An array of data on the request including
1836     * whether or not it was successful
1837     */
1838    public function placeRequest($details, $type = 'Hold')
1839    {
1840        $username = $details['patron']['cat_username'];
1841        $password = $details['patron']['cat_password'];
1842        $bibId = $details['bib_id'];
1843        $itemId = $details['item_id'];
1844        $requestType = $details['holdtype'] ?? $type;
1845        $pickUpLocation = null;
1846        if (isset($details['pickUpLocation'])) {
1847            [, $pickUpLocation] = explode('|', $details['pickUpLocation']);
1848        }
1849
1850        $convertedDate = $this->dateConverter->convertFromDisplayDate(
1851            'U',
1852            $details['requiredBy']
1853        );
1854        $lastInterestDate = \DateTime::createFromFormat('U', $convertedDate);
1855        $lastInterestDate->setTime(23, 59, 59);
1856        $lastInterestDateStr = $lastInterestDate->format('c');
1857        $request = $this->getRequest(
1858            $username,
1859            $password,
1860            $bibId,
1861            $itemId,
1862            $details['patron']['patronAgencyId'],
1863            $details['item_agency_id'],
1864            $requestType,
1865            'Item',
1866            $lastInterestDateStr,
1867            $pickUpLocation,
1868            $username
1869        );
1870        $response = $this->sendRequest($request);
1871
1872        $success = $response->xpath(
1873            'ns1:RequestItemResponse/ns1:ItemId/ns1:ItemIdentifierValue' .
1874            ' | ' .
1875            'ns1:RequestItemResponse/ns1:RequestId/ns1:RequestIdentifierValue'
1876        );
1877
1878        try {
1879            $this->checkResponseForError($response);
1880        } catch (ILSException $exception) {
1881            $failureReturn = ['success' => false];
1882            $problemDescription = $this->getProblemDescription(
1883                $response,
1884                $this->holdProblemsDisplay,
1885                false
1886            );
1887            if (!empty($problemDescription)) {
1888                $failureReturn['sysMessage']
1889                    = $this->translateMessage($problemDescription);
1890            }
1891            return $failureReturn;
1892        }
1893
1894        $this->invalidateResponseCache('LookupUser', $username);
1895        return !empty($success) ? ['success' => true] : ['success' => false];
1896    }
1897
1898    /**
1899     * General cancel request method
1900     *
1901     * Attempts to Cancel a request on a particular item. The data in
1902     * $cancelDetails['details'] is determined by getCancelRequestDetails().
1903     *
1904     * @param array  $cancelDetails An array of item and patron data
1905     * @param string $type          Type of request, could be: 'Hold',
1906     * 'Stack Retrieval'
1907     *
1908     * @return array               An array of data on each request including
1909     * whether or not it was successful.
1910     */
1911    public function handleCancelRequest($cancelDetails, $type = 'Hold')
1912    {
1913        $msgPrefix = ($type === 'Stack Retrieval')
1914            ? 'storage_retrieval_request_cancel_'
1915            : 'hold_cancel_';
1916        $count = 0;
1917        $username = $cancelDetails['patron']['cat_username'];
1918        $password = $cancelDetails['patron']['cat_password'];
1919        $patronAgency = $cancelDetails['patron']['patronAgencyId'];
1920        $details = $cancelDetails['details'];
1921        $patronId = $cancelDetails['patron']['id'] ?? null;
1922        $response = [];
1923        $failureReturn = [
1924            'success' => false,
1925            'status' => $msgPrefix . 'fail',
1926        ];
1927        $successReturn = [
1928            'success' => true,
1929            'status' => $msgPrefix . 'success',
1930        ];
1931
1932        foreach ($details as $detail) {
1933            [$itemAgencyId, $requestId, $itemId] = explode('|', $detail);
1934            $request = $this->getCancelRequest(
1935                $username,
1936                $password,
1937                $patronAgency,
1938                $itemAgencyId,
1939                $requestId,
1940                $type,
1941                $itemId,
1942                $patronId
1943            );
1944            $cancelRequestResponse = $this->sendRequest($request);
1945            $userId = $cancelRequestResponse->xpath(
1946                'ns1:CancelRequestItemResponse/' .
1947                'ns1:UserId/ns1:UserIdentifierValue'
1948            );
1949            $itemId = $itemId ?: $requestId;
1950            try {
1951                $this->checkResponseForError($cancelRequestResponse);
1952            } catch (ILSException $exception) {
1953                $response[$itemId] = $failureReturn;
1954                continue;
1955            }
1956            if ($userId) {
1957                $count++;
1958                $response[$itemId] = $successReturn;
1959            } else {
1960                $response[$itemId] = $failureReturn;
1961            }
1962        }
1963        $this->invalidateResponseCache('LookupUser', $username);
1964        $result = ['count' => $count, 'items' => $response];
1965        return $result;
1966    }
1967
1968    /**
1969     * Cancel Holds
1970     *
1971     * Attempts to Cancel a hold or recall on a particular item. The
1972     * data in $cancelDetails['details'] is determined by getCancelHoldDetails().
1973     *
1974     * @param array $cancelDetails An array of item and patron data
1975     *
1976     * @return array               An array of data on each request including
1977     * whether or not it was successful.
1978     */
1979    public function cancelHolds($cancelDetails)
1980    {
1981        return $this->handleCancelRequest($cancelDetails, 'Hold');
1982    }
1983
1984    /**
1985     * Get Cancel Request Details
1986     *
1987     * General method for getting details for cancel requests
1988     *
1989     * @param array $details An array of item data
1990     *
1991     * @return string Data for use in a form field
1992     */
1993    public function getCancelRequestDetails($details)
1994    {
1995        if ($details['available']) {
1996            return '';
1997        }
1998        return $details['item_agency_id'] .
1999            '|' . $details['requestId'] .
2000            '|' . $details['item_id'];
2001    }
2002
2003    /**
2004     * Get Cancel Hold Details
2005     *
2006     * This function returns the item id and recall id as a string
2007     * separated by a pipe, which is then submitted as form data in Hold.php. This
2008     * value is then extracted by the CancelHolds function. Item id is used as the
2009     * array key in the response.
2010     *
2011     * @param array $holdDetails A single hold array from getMyHolds
2012     * @param array $patron      Patron information from patronLogin
2013     *
2014     * @return string Data for use in a form field
2015     *
2016     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
2017     */
2018    public function getCancelHoldDetails($holdDetails, $patron = [])
2019    {
2020        return $this->getCancelRequestDetails($holdDetails);
2021    }
2022
2023    /**
2024     * Cancel Storage Retrieval Requests (Call Slips)
2025     *
2026     * Attempts to Cancel a call slip on a particular item. The
2027     * data in $cancelDetails['details'] is determined by
2028     * getCancelStorageRetrievalRequestDetails().
2029     *
2030     * @param array $cancelDetails An array of item and patron data
2031     *
2032     * @return array               An array of data on each request including
2033     * whether or not it was successful.
2034     */
2035    public function cancelStorageRetrievalRequests($cancelDetails)
2036    {
2037        return $this->handleCancelRequest($cancelDetails, 'Stack Retrieval');
2038    }
2039
2040    /**
2041     * Get Cancel Storage Retrieval Request (Call Slip) Details
2042     *
2043     * This function returns the item id and call slip id as a
2044     * string separated by a pipe, which is then submitted as form data. This
2045     * value is then extracted by the CancelStorageRetrievalRequests function.
2046     * The item id is used as the key in the return value.
2047     *
2048     * @param array $details An array of item data
2049     * @param array $patron  Patron information from patronLogin
2050     *
2051     * @return string Data for use in a form field
2052     *
2053     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
2054     */
2055    public function getCancelStorageRetrievalRequestDetails($details, $patron)
2056    {
2057        return $this->getCancelRequestDetails($details);
2058    }
2059
2060    /**
2061     * Renew My Items
2062     *
2063     * Function for attempting to renew a patron's items. The data in
2064     * $renewDetails['details'] is determined by getRenewDetails().
2065     *
2066     * @param array $renewDetails An array of data required for renewing items
2067     * including the Patron ID and an array of renewal IDS
2068     *
2069     * @return array              An array of renewal information keyed by item ID
2070     */
2071    public function renewMyItems($renewDetails)
2072    {
2073        $details = [];
2074        $username = $renewDetails['patron']['cat_username'];
2075        foreach ($renewDetails['details'] as $detail) {
2076            [$agencyId, $itemId] = explode('|', $detail);
2077            $failureReturn = [
2078                'success' => false,
2079                'item_id' => $itemId,
2080            ];
2081            if ($this->disableRenewals) {
2082                $details[$itemId] = $failureReturn;
2083                continue;
2084            }
2085            $request = $this->getRenewRequest(
2086                $username,
2087                $renewDetails['patron']['cat_password'],
2088                $itemId,
2089                $agencyId,
2090                $renewDetails['patron']['patronAgencyId'],
2091                $username
2092            );
2093            $response = $this->sendRequest($request);
2094            $dueDateXml = $response->xpath('ns1:RenewItemResponse/ns1:DateDue');
2095            $dueDate = '';
2096            $dueTime = '';
2097            if (!empty($dueDateXml)) {
2098                $dueDateString = (string)$dueDateXml[0];
2099                $dueDate = $this->displayDate($dueDateString);
2100                $dueTime = $this->displayTime($dueDateString);
2101            }
2102
2103            if ($dueDate !== '') {
2104                $details[$itemId] = [
2105                    'success' => true,
2106                    'new_date' => $dueDate,
2107                    'new_time' => $dueTime,
2108                    'item_id' => $itemId,
2109                ];
2110            } else {
2111                $details[$itemId] = $failureReturn;
2112            }
2113        }
2114        $this->invalidateResponseCache('LookupUser', $username);
2115        return [ 'blocks' => false, 'details' => $details];
2116    }
2117
2118    /**
2119     * Check whether the patron has any blocks on their account.
2120     *
2121     * @param array $patron Patron data from patronLogin().
2122     *
2123     * @return mixed A boolean false if no blocks are in place and an array
2124     * of block reasons if blocks are in place
2125     * @throws ILSException
2126     */
2127    public function getAccountBlocks($patron)
2128    {
2129        $blocks = $this->getPatronBlocks($patron);
2130        $blocks = array_map(
2131            function ($block) {
2132                return $this->translateMessage($this->blockCodes[$block] ?? $block);
2133            },
2134            $blocks
2135        );
2136        return empty($blocks) ? false : array_values(array_unique($blocks));
2137    }
2138
2139    /**
2140     * Helper function to build the request XML to cancel a request:
2141     *
2142     * @param string $username     Username for login
2143     * @param string $password     Password for login
2144     * @param string $patronAgency Agency for patron
2145     * @param string $itemAgencyId Agency ID for item
2146     * @param string $requestId    Id of the request to cancel
2147     * @param string $type         The type of request to cancel (Hold, etc)
2148     * @param string $itemId       Item identifier
2149     * @param string $patronId     Patron identifier
2150     *
2151     * @return string           NCIP request XML
2152     */
2153    protected function getCancelRequest(
2154        $username,
2155        $password,
2156        $patronAgency,
2157        $itemAgencyId,
2158        $requestId,
2159        $type,
2160        $itemId,
2161        $patronId = null
2162    ) {
2163        if (empty($requestId) && empty($itemId)) {
2164            throw new ILSException('No identifiers for CancelRequest');
2165        }
2166        $itemAgencyId = $this->determineToAgencyId($itemAgencyId);
2167        $ret = $this->getNCIPMessageStart() .
2168            '<ns1:CancelRequestItem>' .
2169            $this->getInitiationHeaderXml($patronAgency) .
2170            $this->getAuthenticationInputXml($username, $password);
2171
2172        $ret .= $this->getUserIdXml($patronAgency, $patronId);
2173
2174        if (!empty($requestId)) {
2175            $ret .=
2176                '<ns1:RequestId>' .
2177                    $this->element('AgencyId', $itemAgencyId) .
2178                    $this->element('RequestIdentifierValue', $requestId) .
2179                '</ns1:RequestId>';
2180        }
2181        if (!empty($itemId)) {
2182            $ret .= $this->getItemIdXml($itemAgencyId, $itemId);
2183        }
2184        $ret .= $this->getRequestTypeXml($type) .
2185            '</ns1:CancelRequestItem></ns1:NCIPMessage>';
2186        return $ret;
2187    }
2188
2189    /**
2190     * Helper function to build the request XML to request an item
2191     * (Hold, Storage Retrieval, etc)
2192     *
2193     * @param string $username         Username for login
2194     * @param string $password         Password for login
2195     * @param string $bibId            Bib Id of item to request
2196     * @param string $itemId           Id of item to request
2197     * @param string $patronAgencyId   Patron agency ID
2198     * @param string $itemAgencyId     Item agency ID
2199     * @param string $requestType      Type of the request (Hold, Callslip, etc)
2200     * @param string $requestScope     Level of request (title, item, etc)
2201     * @param string $lastInterestDate Last date interested in item
2202     * @param string $pickupLocation   Code of location to pickup request
2203     * @param string $patronId         Patron internal identifier
2204     *
2205     * @return string          NCIP request XML
2206     */
2207    protected function getRequest(
2208        $username,
2209        $password,
2210        $bibId,
2211        $itemId,
2212        $patronAgencyId,
2213        $itemAgencyId,
2214        $requestType,
2215        $requestScope,
2216        $lastInterestDate,
2217        $pickupLocation = null,
2218        $patronId = null
2219    ) {
2220        $ret = $this->getNCIPMessageStart() .
2221            '<ns1:RequestItem>' .
2222            $this->getInitiationHeaderXml($patronAgencyId) .
2223            $this->getAuthenticationInputXml($username, $password) .
2224            $this->getUserIdXml($patronAgencyId, $patronId) .
2225            $this->getBibliographicId($bibId) .
2226            $this->getItemIdXml($itemAgencyId, $itemId) .
2227            $this->getRequestTypeXml($requestType, $requestScope);
2228
2229        if (!empty($pickupLocation)) {
2230            $ret .= $this->element('PickupLocation', $pickupLocation);
2231        }
2232        if (!empty($lastInterestDate)) {
2233            $ret .= $this->element('NeedBeforeDate', $lastInterestDate);
2234        }
2235        $ret .= '</ns1:RequestItem></ns1:NCIPMessage>';
2236        return $ret;
2237    }
2238
2239    /**
2240     * Helper function to build the request XML to renew an item:
2241     *
2242     * @param string $username       Username for login
2243     * @param string $password       Password for login
2244     * @param string $itemId         Id of item to renew
2245     * @param string $itemAgencyId   Agency of Item Id to renew
2246     * @param string $patronAgencyId Agency of patron
2247     * @param string $patronId       Internal patron id
2248     *
2249     * @return string          NCIP request XML
2250     */
2251    protected function getRenewRequest(
2252        $username,
2253        $password,
2254        $itemId,
2255        $itemAgencyId,
2256        $patronAgencyId,
2257        $patronId = null
2258    ) {
2259        $itemAgencyId = $this->determineToAgencyId($itemAgencyId);
2260        return $this->getNCIPMessageStart() .
2261            '<ns1:RenewItem>' .
2262            $this->getInitiationHeaderXml($patronAgencyId) .
2263            $this->getAuthenticationInputXml($username, $password) .
2264            $this->getUserIdXml($patronAgencyId, $patronId) .
2265            $this->getItemIdXml($itemAgencyId, $itemId) .
2266            '</ns1:RenewItem></ns1:NCIPMessage>';
2267    }
2268
2269    /**
2270     * Helper function to build the request XML to log in a user
2271     * and/or retrieve loaned items / request information
2272     *
2273     * @param string $username       Username for login
2274     * @param string $password       Password for login
2275     * @param string $patronAgencyId Patron agency ID (optional)
2276     * @param array  $extras         Extra elements to include in the request
2277     * @param string $patronId       Patron internal identifier
2278     *
2279     * @return string          NCIP request XML
2280     */
2281    protected function getLookupUserRequest(
2282        $username,
2283        $password,
2284        $patronAgencyId = null,
2285        $extras = [],
2286        $patronId = null
2287    ) {
2288        return $this->getNCIPMessageStart() .
2289            '<ns1:LookupUser>' .
2290            $this->getInitiationHeaderXml($patronAgencyId) .
2291            $this->getAuthenticationInputXml($username, $password) .
2292            $this->getUserIdXml($patronAgencyId, $patronId) .
2293            implode('', $extras) .
2294            '</ns1:LookupUser></ns1:NCIPMessage>';
2295    }
2296
2297    /**
2298     * Get LookupAgency Request XML message
2299     *
2300     * @param string|null $agency Agency Id
2301     *
2302     * @return string XML Document
2303     */
2304    public function getLookupAgencyRequest($agency = null)
2305    {
2306        $agency = $this->determineToAgencyId($agency);
2307
2308        $ret = $this->getNCIPMessageStart() .
2309            '<ns1:LookupAgency>' .
2310            $this->getInitiationHeaderXml($agency) .
2311            $this->element('AgencyId', $agency);
2312
2313        $desiredElementTypes = [
2314            'Agency Address Information', 'Agency User Privilege Type',
2315            'Application Profile Supported Type', 'Authentication Prompt',
2316            'Consortium Agreement', 'Organization Name Information',
2317        ];
2318        foreach ($desiredElementTypes as $elementType) {
2319            $ret .= $this->element('AgencyElementType', $elementType);
2320        }
2321        $ret .= '</ns1:LookupAgency></ns1:NCIPMessage>';
2322        return $ret;
2323    }
2324
2325    /**
2326     * Create Lookup Item Request
2327     *
2328     * @param string  $itemId       Item identifier
2329     * @param ?string $idType       Item identifier type
2330     * @param array   $desiredParts Needed data, available options are:
2331     * 'Bibliographic Description', 'Circulation Status', 'Electronic Resource',
2332     * 'Hold Queue Length', 'Date Due', 'Item Description',
2333     * 'Item Use Restriction Type', 'Location', 'Physical Condition',
2334     * 'Security Marker', 'Sensitization Flag'
2335     *
2336     * @return string XML document
2337     */
2338    protected function getLookupItemRequest(
2339        string $itemId,
2340        ?string $idType = null,
2341        array $desiredParts = ['Bibliographic Description']
2342    ): string {
2343        $agency = $this->determineToAgencyId();
2344        $ret = $this->getNCIPMessageStart() .
2345            '<ns1:LookupItem>' .
2346            $this->getInitiationHeaderXml($agency) .
2347            $this->getItemIdXml($agency, $itemId, $idType);
2348        foreach ($desiredParts as $current) {
2349            $ret .= $this->element('ItemElementType', $current);
2350        }
2351        $ret .= '</ns1:LookupItem></ns1:NCIPMessage>';
2352        return $ret;
2353    }
2354
2355    /**
2356     * Get InitiationHeader element XML string
2357     *
2358     * @param string $agency Agency of NCIP responder
2359     *
2360     * @return string
2361     */
2362    protected function getInitiationHeaderXml($agency = null)
2363    {
2364        $agency = $this->determineToAgencyId($agency);
2365        if (empty($agency) || empty($this->fromAgency)) {
2366            return '';
2367        }
2368        return '<ns1:InitiationHeader>' .
2369                '<ns1:FromAgencyId>' .
2370                    $this->element('AgencyId', $this->fromAgency) .
2371                '</ns1:FromAgencyId>' .
2372                '<ns1:ToAgencyId>' .
2373                    $this->element('AgencyId', $agency) .
2374                '</ns1:ToAgencyId>' .
2375            '</ns1:InitiationHeader>';
2376    }
2377
2378    /**
2379     * Helper method for creating XML header and main element start
2380     *
2381     * @return string
2382     */
2383    protected function getNCIPMessageStart()
2384    {
2385        return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' .
2386            '<ns1:NCIPMessage xmlns:ns1="http://www.niso.org/2008/ncip" ' .
2387            'ns1:version="http://www.niso.org/schemas/ncip/v2_02/ncip_v2_02.xsd">';
2388    }
2389
2390    /**
2391     * Get XML string for AuthenticationInput element
2392     *
2393     * @param string $username User login
2394     * @param string $password User password
2395     *
2396     * @return string XML string for AuthenticationInput element
2397     */
2398    protected function getAuthenticationInputXml($username, $password)
2399    {
2400        return (!empty($username) && !empty($password))
2401            ? '<ns1:AuthenticationInput>' .
2402                $this->element('AuthenticationInputData', $username) .
2403                $this->element('AuthenticationDataFormatType', 'text') .
2404                $this->element('AuthenticationInputType', 'Username') .
2405            '</ns1:AuthenticationInput>' .
2406            '<ns1:AuthenticationInput>' .
2407                $this->element('AuthenticationInputData', $password) .
2408                $this->element('AuthenticationDataFormatType', 'text') .
2409                $this->element('AuthenticationInputType', 'Password') .
2410            '</ns1:AuthenticationInput>'
2411            : '';
2412    }
2413
2414    /**
2415     * Get ItemId element XML
2416     *
2417     * @param string      $agency Agency id
2418     * @param string      $itemId Item id
2419     * @param null|string $idType Item id type
2420     *
2421     * @return string ItemId element XML string
2422     */
2423    protected function getItemIdXml($agency, $itemId, $idType = null)
2424    {
2425        $agency = $this->determineToAgencyId($agency);
2426        $ret = '<ns1:ItemId>' . $this->element('AgencyId', $agency);
2427        if ($idType !== null) {
2428            $ret .= $this->element('ItemIdentifierType', $idType);
2429        }
2430        $ret .= $this->element('ItemIdentifierValue', $itemId);
2431        $ret .= '</ns1:ItemId>';
2432        return $ret;
2433    }
2434
2435    /**
2436     * Get UserId element XML
2437     *
2438     * @param string $patronAgency Patron agency id
2439     * @param string $patronId     Internal patron identifier
2440     *
2441     * @return string Get UserId element XML string
2442     */
2443    protected function getUserIdXml($patronAgency, $patronId = null)
2444    {
2445        $agency = $this->determineToAgencyId($patronAgency);
2446        if ($patronId !== null) {
2447            return '<ns1:UserId>' .
2448                $this->element('AgencyId', $agency) .
2449                $this->element('UserIdentifierType', 'Institution Id Number') .
2450                $this->element('UserIdentifierValue', $patronId) .
2451            '</ns1:UserId>';
2452        }
2453        return '';
2454    }
2455
2456    /**
2457     * Get request type elements XML
2458     *
2459     * @param string $type  Request type
2460     * @param string $scope Request type scope (defaults to 'Bibliographic Item')
2461     *
2462     * @return string RequestType and RequestScopeType element XML string
2463     */
2464    protected function getRequestTypeXml($type, $scope = 'Bibliographic Item')
2465    {
2466        return
2467            $this->element('RequestType', $type) .
2468            $this->element('RequestScopeType', $scope);
2469    }
2470
2471    /**
2472     * Get BibliographicId element
2473     *
2474     * @param string $id Bibliographic item id
2475     *
2476     * @return string Get BibliographicId XML element string
2477     */
2478    protected function getBibliographicId($id)
2479    {
2480        return '<ns1:BibliographicId>' .
2481            '<ns1:BibliographicItemId>' .
2482                $this->element('BibliographicItemIdentifier', $id) .
2483                $this->element(
2484                    'BibliographicItemIdentifierCode',
2485                    'Legal Deposit Number'
2486                ) .
2487            '</ns1:BibliographicItemId>' .
2488        '</ns1:BibliographicId>';
2489    }
2490
2491    /**
2492     * Throw an exception if an NCIP error is found
2493     *
2494     * @param \SimpleXMLElement $response from NCIP call
2495     *
2496     * @throws ILSException
2497     * @return void
2498     */
2499    protected function checkResponseForError($response)
2500    {
2501        $error = $response->xpath(
2502            '//ns1:Problem'
2503        );
2504        if (!empty($error)) {
2505            throw new ILSException($error[0]);
2506        }
2507    }
2508
2509    /**
2510     * Register namespace(s) for an XML element/tree
2511     *
2512     * @param \SimpleXMLElement $element Element to register namespace for
2513     *
2514     * @return void
2515     */
2516    protected function registerNamespaceFor(\SimpleXMLElement $element)
2517    {
2518        $element->registerXPathNamespace('ns1', 'http://www.niso.org/2008/ncip');
2519    }
2520
2521    /**
2522     * Convert a date to display format
2523     *
2524     * @param string $date Date and time string
2525     *
2526     * @return string
2527     */
2528    protected function displayDate($date)
2529    {
2530        return $this->convertDateOrTime($date);
2531    }
2532
2533    /**
2534     * Convert a time to display format
2535     *
2536     * @param string $date Date and time string
2537     *
2538     * @return string
2539     */
2540    protected function displayTime($date)
2541    {
2542        return $this->convertDateOrTime($date, 'time');
2543    }
2544
2545    /**
2546     * Convert datetime to display format
2547     *
2548     * @param string $dateString Datetime string
2549     * @param string $dateOrTime Desired datetime part, could be 'date' or 'time'
2550     *
2551     * @return string
2552     */
2553    protected function convertDateOrTime($dateString, $dateOrTime = 'date')
2554    {
2555        if (!$dateString) {
2556            return '';
2557        }
2558        $createFormats = ['Y-m-d\TH:i:s.uP', 'Y-m-d\TH:i:sP'];
2559        $formatted = '';
2560        foreach ($createFormats as $format) {
2561            try {
2562                $formatted = ($dateOrTime === 'time')
2563                    ? $this->dateConverter->convertToDisplayTime(
2564                        $format,
2565                        $dateString
2566                    )
2567                    : $this->dateConverter->convertToDisplayDate(
2568                        $format,
2569                        $dateString
2570                    );
2571            } catch (DateException $exception) {
2572                continue;
2573            }
2574        }
2575        return $formatted;
2576    }
2577
2578    /**
2579     * Get Hold Type
2580     *
2581     * @param string $status Status string from CirculationStatus NCIP element
2582     *
2583     * @return string Hold type, could be 'Hold' or 'Recall'
2584     */
2585    protected function getHoldType(string $status)
2586    {
2587        return in_array(strtolower($status), $this->availableStatuses)
2588            ? 'Hold' : 'Recall';
2589    }
2590
2591    /**
2592     * Is an item available?
2593     *
2594     * @param string $status Status string from CirculationStatus NCIP element
2595     *
2596     * @return bool Return true if item is available
2597     */
2598    protected function isAvailable(string $status)
2599    {
2600        return in_array(strtolower($status), $this->availableStatuses);
2601    }
2602
2603    /**
2604     * Is request cancelled?
2605     *
2606     * @param string $status Status string from RequestStatusType NCIP element
2607     *
2608     * @return bool Return true if a request was cancelled
2609     */
2610    protected function isRequestCancelled(string $status)
2611    {
2612        return !in_array(strtolower($status), $this->activeRequestStatuses);
2613    }
2614
2615    /**
2616     * Is request of desired type?
2617     *
2618     * @param \SimpleXMLElement $request RequestedItem NCIP Element
2619     * @param array             $types   Array of types to check against
2620     *
2621     * @return bool Return true if request is of desired type
2622     */
2623    protected function checkRequestType(\SimpleXMLElement $request, array $types)
2624    {
2625        $requestType = $request->xpath('ns1:RequestType');
2626        $requestType = (string)$requestType[0];
2627        return in_array(strtolower($requestType), $types);
2628    }
2629
2630    /**
2631     * Check if item is holdable
2632     *
2633     * @param \SimpleXMLElement $itemInformation Item information element
2634     *
2635     * @return bool
2636     */
2637    protected function isItemHoldable(\SimpleXMLElement $itemInformation): bool
2638    {
2639        $restrictions = $itemInformation->xpath(
2640            'ns1:ItemOptionalFields/ns1:ItemUseRestrictionType'
2641        );
2642        foreach ($restrictions as $restriction) {
2643            $restStr = strtolower((string)$restriction);
2644            if (in_array($restStr, $this->notHoldableRestriction)) {
2645                return false;
2646            }
2647        }
2648        $statuses = $itemInformation->xpath(
2649            'ns1:ItemOptionalFields/ns1:CirculationStatus'
2650        );
2651        foreach ($statuses as $status) {
2652            $statusStr = strtolower((string)$status);
2653            if (in_array($statusStr, $this->notHoldableStatuses)) {
2654                return false;
2655            }
2656        }
2657
2658        return true;
2659    }
2660
2661    /**
2662     * Determine ToAgencyId
2663     *
2664     * @param array|string|null $agency List of available (configured) agencies or
2665     * Agency Id
2666     *
2667     * @return string|null First Agency Id found
2668     */
2669    protected function determineToAgencyId($agency = null)
2670    {
2671        // FIXME: We are using the first defined agency, it will probably not work in
2672        // consortium scenario
2673        if (empty($agency)) {
2674            $keys = array_keys($this->agency);
2675            $agency = $keys[0];
2676        }
2677
2678        return is_array($agency) ? $agency[0] : $agency;
2679    }
2680
2681    /**
2682     * Get Lookup user response
2683     *
2684     * @param string      $username User name
2685     * @param string|null $password User password
2686     *
2687     * @return \SimpleXMLElement
2688     * @throws ILSException
2689     */
2690    protected function getLookupUserResponse(
2691        string $username,
2692        ?string $password = null
2693    ): \SimpleXMLElement {
2694        if (isset($this->responses['LookupUser'][$username])) {
2695            return $this->responses['LookupUser'][$username];
2696        }
2697        $extras = $this->getLookupUserExtras();
2698        $request = $this->getLookupUserRequest(
2699            $username,
2700            $password,
2701            $this->determineToAgencyId(),
2702            $extras,
2703            $username
2704        );
2705        $response = $this->sendRequest($request);
2706        $this->checkResponseForError($response);
2707        $this->responses['LookupUser'][$username] = $response;
2708        return $response;
2709    }
2710
2711    /**
2712     * Creates array for Lookup user desired information
2713     *
2714     * @return array
2715     */
2716    protected function getLookupUserExtras(): array
2717    {
2718        return [
2719            $this->element('UserElementType', 'User Address Information'),
2720            $this->element('UserElementType', 'Name Information'),
2721            $this->element('UserElementType', 'User Privilege'),
2722            $this->element('UserElementType', 'Block Or Trap'),
2723            '<ns1:LoanedItemsDesired />',
2724            '<ns1:RequestedItemsDesired />',
2725            '<ns1:UserFiscalAccountDesired />',
2726        ];
2727    }
2728
2729    /**
2730     * Parse http response into XML object representation
2731     *
2732     * @param string $xmlString XML string
2733     *
2734     * @return \SimpleXMLElement
2735     * @throws ILSException
2736     */
2737    protected function parseXml(string $xmlString): \SimpleXMLElement
2738    {
2739        $result = @simplexml_load_string($xmlString);
2740        if ($result === false) {
2741            throw new ILSException('Problem parsing XML: ' . $xmlString);
2742        }
2743        // If no namespaces are used, add default one and reload the document
2744        if (empty($result->getNamespaces())) {
2745            $result->addAttribute('xmlns', 'http://www.niso.org/2008/ncip');
2746            $xml = $result->asXML();
2747            $result = @simplexml_load_string($xml);
2748            if ($result === false) {
2749                throw new ILSException('Problem parsing XML: ' . $xmlString);
2750            }
2751        }
2752        $this->registerNamespaceFor($result);
2753        return $result;
2754    }
2755
2756    /**
2757     * Parse all reported problem and return its string representation
2758     *
2759     * @param string $xmlString XML string
2760     *
2761     * @return string
2762     */
2763    protected function parseProblem(string $xmlString): string
2764    {
2765        $xml = $this->parseXml($xmlString);
2766        $problems = $xml->xpath('//ns1:Problem');
2767        if (empty($problems)) {
2768            return 'Cannot identify problem in response: ' . $xmlString;
2769        }
2770        return $this->getProblemDescription($xml);
2771    }
2772
2773    /**
2774     * Get problem description as one string
2775     *
2776     * @param \SimpleXMLElement $xml              XML response
2777     * @param array|string[]    $elements         Which of Problem subelements
2778     * return in description - defaulting to full list: ProblemType, ProblemDetail,
2779     * ProblemElement and ProblemValue
2780     * @param bool              $withElementNames Whether to add element names as
2781     * value labels (for example for debug purposes)
2782     *
2783     * @return string
2784     */
2785    protected function getProblemDescription(
2786        \SimpleXMLElement $xml,
2787        array $elements = [
2788            'ProblemType', 'ProblemDetail', 'ProblemElement', 'ProblemValue',
2789        ],
2790        bool $withElementNames = true
2791    ): string {
2792        $problems = $xml->xpath('//ns1:Problem');
2793        if (empty($problems)) {
2794            return '';
2795        }
2796        $allProblems = [];
2797        foreach ($problems as $problem) {
2798            $this->registerNamespaceFor($problem);
2799            $oneProblem = [];
2800            foreach ($elements as $element) {
2801                $detail = $problem->xpath('ns1:' . $element);
2802                if (!empty($detail)) {
2803                    $oneProblem[] = $withElementNames
2804                        ? $element . ': ' . (string)$detail[0]
2805                        : (string)$detail[0];
2806                }
2807            }
2808            $allProblems[] = implode(', ', $oneProblem);
2809        }
2810        return implode(', ', $allProblems);
2811    }
2812
2813    /**
2814     * Creates scheme attribute based on $this->schemes array
2815     *
2816     * @param string $element         Element name
2817     * @param string $namespacePrefix Namespace identifier
2818     *
2819     * @return string Scheme attribute or empty string
2820     */
2821    protected function schemeAttr(string $element, $namespacePrefix = 'ns1'): string
2822    {
2823        return isset($this->schemes[$element])
2824            ? ' ' . $namespacePrefix . ':Scheme="' . $this->schemes[$element] . '"'
2825            : '';
2826    }
2827
2828    /**
2829     * Creates simple element as XML string
2830     *
2831     * @param string $elementName     Element name
2832     * @param string $text            Content of element
2833     * @param string $namespacePrefix Namespace
2834     *
2835     * @return string XML string
2836     */
2837    protected function element(
2838        string $elementName,
2839        string $text,
2840        string $namespacePrefix = 'ns1'
2841    ): string {
2842        $fullElementName = $namespacePrefix . ':' . $elementName;
2843        return '<' . $fullElementName .
2844            $this->schemeAttr($elementName, $namespacePrefix) . '>' .
2845            htmlspecialchars($text) .
2846            '</' . $fullElementName . '>';
2847    }
2848
2849    /**
2850     * Parse the LocationNameInstanceElement for multi-level locations
2851     *
2852     * @param array $locations Array of \SimpleXMLElement objects for
2853     * LocationNameInstance element
2854     *
2855     * @return array Two item, 1st and 2nd level from LocationNameInstance
2856     */
2857    protected function parseLocationInstance(array $locations): array
2858    {
2859        $location = $collection = null;
2860        $initialLevel = 0;
2861        foreach ($locations as $loc) {
2862            $this->registerNamespaceFor($loc);
2863            $name = $loc->xpath('ns1:LocationNameValue');
2864            $name = (string)($name[0] ?? '');
2865            $level = $loc->xpath('ns1:LocationNameLevel');
2866            $level = !empty($level) ? (int)($level[0]) : 1;
2867            if ($initialLevel === 0) {
2868                $initialLevel = $level;
2869            }
2870            if ($level === $initialLevel) {
2871                $location = $name;
2872            } elseif ($level === $initialLevel + 1) {
2873                $collection = $name;
2874            }
2875        }
2876        return [$location, $collection];
2877    }
2878
2879    /**
2880     * Translate a message from ILS
2881     *
2882     * @param string $message Message to be translated
2883     *
2884     * @return string
2885     */
2886    protected function translateMessage(string $message): string
2887    {
2888        return $this->translate($this->translationDomain . '::' . $message);
2889    }
2890
2891    /**
2892     * Invalidate L1 cache for responses
2893     *
2894     * @param string $message NCIP message type - currently only 'LookupUser'
2895     * @param string $key     Cache key (For LookupUser its cat_username)
2896     *
2897     * @return void
2898     */
2899    protected function invalidateResponseCache(string $message, string $key): void
2900    {
2901        unset($this->responses['LookupUser'][$key]);
2902    }
2903
2904    /**
2905     * Get all bibliographic records
2906     *
2907     * @param array $idList     List of bibliographic IDs.
2908     * @param array $agencyList List of possible toAgency values
2909     *
2910     * @return \SimpleXMLElement[]
2911     * @throws ILSException
2912     */
2913    protected function getBibs(array $idList, array $agencyList): array
2914    {
2915        $bibs = [];
2916        $nextItemToken = [];
2917        $request = true;
2918        $page = 0;
2919        while ($request) {
2920            $page++;
2921            $resumption = !empty($nextItemToken) ? (string)$nextItemToken[0] : null;
2922            $request = $this->getStatusRequest($idList, $resumption, $agencyList);
2923            $response = $this->sendRequest($request);
2924            $bibs = array_merge(
2925                $bibs,
2926                $response->xpath('ns1:LookupItemSetResponse/ns1:BibInformation')
2927            );
2928            $nextItemToken = $response->xpath('//ns1:NextItemToken');
2929            $request = $this->isNextItemTokenEmpty($nextItemToken);
2930            if ($page == $this->maxNumberOfPages) {
2931                break;
2932            }
2933        }
2934        return $bibs;
2935    }
2936
2937    /**
2938     * Check NextItemToken for emptiness
2939     *
2940     * @param \SimpleXMLElement[] $nextItemToken Next item token elements from NCIP
2941     * Response
2942     *
2943     * @return bool
2944     */
2945    protected function isNextItemTokenEmpty(array $nextItemToken): bool
2946    {
2947        return !empty($nextItemToken) && (string)$nextItemToken[0] !== '';
2948    }
2949}