Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
4.24% covered (danger)
4.24%
50 / 1179
2.90% covered (danger)
2.90%
2 / 69
CRAP
0.00% covered (danger)
0.00%
0 / 1
KohaRest
4.24% covered (danger)
4.24%
50 / 1179
2.90% covered (danger)
2.90%
2 / 69
82527.10
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 init
54.05% covered (warning)
54.05%
20 / 37
0.00% covered (danger)
0.00%
0 / 1
14.21
 getCacheKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStatus
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStatuses
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getHolding
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPurchaseHistory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getNewItems
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDepartments
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getInstructors
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 getCourses
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 findReserves
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
42
 patronLogin
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
90
 getRequestBlocks
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAccountBlocks
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMyProfile
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 getMyTransactions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRenewDetails
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 renewMyItems
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 getMyTransactionHistory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 purgeTransactionHistory
78.26% covered (warning)
78.26%
18 / 23
0.00% covered (danger)
0.00%
0 / 1
3.09
 getMyHolds
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
156
 cancelHolds
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 getPickUpLocations
0.00% covered (danger)
0.00%
0 / 66
0.00% covered (danger)
0.00%
0 / 1
600
 getDefaultPickUpLocation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 checkRequestIsValid
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
42
 placeHold
0.00% covered (danger)
0.00%
0 / 54
0.00% covered (danger)
0.00%
0 / 1
156
 updateHolds
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
132
 getMyStorageRetrievalRequests
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
30
 getCancelStorageRetrievalRequestDetails
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 cancelStorageRetrievalRequests
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
12
 checkStorageRetrievalRequestIsValid
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 placeStorageRetrievalRequest
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
56
 getMyFines
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
42
 changePassword
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 getUrlsForRecord
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getConfig
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
20
 supportsMethod
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 createHttpClient
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
42
 makeRequest
0.00% covered (danger)
0.00%
0 / 71
0.00% covered (danger)
0.00%
0 / 1
380
 getOAuth2Token
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
 getItemStatusesForBiblio
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 1
132
 getItemStatusCodes
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
110
 getStatusCodeItemCheckedOut
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getStatusCodeItemNotForLoanOrLost
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getStatusCodeItemTransfer
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 statusSortFunction
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 itemHoldAllowed
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 itemArticleRequestAllowed
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 pickStatus
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getStatusRanking
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLibraries
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getLibraryName
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getPatronBlocks
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
72
 getItem
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getBiblio
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 pickUpLocationIsValid
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 holdError
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 mapRenewalBlockReason
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getItemLocationName
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 translateLocation
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getItemCallNumber
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHoldBlockReason
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
240
 getSortParamValue
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getBiblioTitle
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 convertDate
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getTransactions
0.00% covered (danger)
0.00%
0 / 82
0.00% covered (danger)
0.00%
0 / 1
182
 getPatronBlockReason
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 formatMoney
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3/**
4 * VuFind Driver for Koha, using REST API
5 *
6 * PHP version 8
7 *
8 * Copyright (C) The National Library of Finland 2016-2023.
9 * Copyright (C) Moravian Library 2019.
10 *
11 * This program is free software; you can redistribute it and/or modify
12 * it under the terms of the GNU General Public License version 2,
13 * as published by the Free Software Foundation.
14 *
15 * This program is distributed in the hope that it will be useful,
16 * but WITHOUT ANY WARRANTY; without even the implied warranty of
17 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18 * GNU General Public License for more details.
19 *
20 * You should have received a copy of the GNU General Public License
21 * along with this program; if not, write to the Free Software
22 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
23 *
24 * @category VuFind
25 * @package  ILS_Drivers
26 * @author   Bohdan Inhliziian <bohdan.inhliziian@gmail.com.cz>
27 * @author   Ere Maijala <ere.maijala@helsinki.fi>
28 * @author   Josef Moravec <josef.moravec@mzk.cz>
29 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
30 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
31 */
32
33namespace VuFind\ILS\Driver;
34
35use VuFind\Date\DateException;
36use VuFind\Exception\AuthToken as AuthTokenException;
37use VuFind\Exception\ILS as ILSException;
38use VuFind\Service\CurrencyFormatter;
39
40use function array_key_exists;
41use function call_user_func;
42use function count;
43use function in_array;
44use function is_array;
45use function is_callable;
46use function is_string;
47
48/**
49 * VuFind Driver for Koha, using REST API
50 *
51 * Minimum Koha Version: 20.05 + koha-plugin-rest-di REST API plugin from
52 * https://github.com/natlibfi/koha-plugin-rest-di
53 *
54 * @category VuFind
55 * @package  ILS_Drivers
56 * @author   Bohdan Inhliziian <bohdan.inhliziian@gmail.com.cz>
57 * @author   Ere Maijala <ere.maijala@helsinki.fi>
58 * @author   Josef Moravec <josef.moravec@mzk.cz>
59 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
60 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
61 */
62class KohaRest extends \VuFind\ILS\Driver\AbstractBase implements
63    \VuFindHttp\HttpServiceAwareInterface,
64    \VuFind\I18n\Translator\TranslatorAwareInterface,
65    \Laminas\Log\LoggerAwareInterface,
66    \VuFind\I18n\HasSorterInterface
67{
68    use \VuFindHttp\HttpServiceAwareTrait;
69    use \VuFind\I18n\Translator\TranslatorAwareTrait;
70    use \VuFind\Cache\CacheTrait;
71    use \VuFind\ILS\Driver\OAuth2TokenTrait;
72    use \VuFind\I18n\HasSorterTrait;
73
74    /**
75     * Library prefix
76     *
77     * @var string
78     */
79    protected $source = '';
80
81    /**
82     * Date converter object
83     *
84     * @var \VuFind\Date\Converter
85     */
86    protected $dateConverter;
87
88    /**
89     * Factory function for constructing the SessionContainer.
90     *
91     * @var callable
92     */
93    protected $sessionFactory;
94
95    /**
96     * Currency formatter
97     *
98     * @var CurrencyFormatter
99     */
100    protected $currencyFormatter;
101
102    /**
103     * Session cache
104     *
105     * @var \Laminas\Session\Container
106     */
107    protected $sessionCache;
108
109    /**
110     * Validate passwords
111     *
112     * @var bool
113     */
114    protected $dontValidatePasswords = false;
115
116    /**
117     * Default pickup location
118     *
119     * @var string
120     */
121    protected $defaultPickUpLocation;
122
123    /**
124     * Whether to allow canceling holds in transit. Default is false.
125     *
126     * @var bool
127     */
128    protected $allowCancelInTransit = false;
129
130    /**
131     * Item status rankings. The lower the value, the more important the status.
132     *
133     * @var array
134     */
135    protected $statusRankings = [
136        'Charged' => 1,
137        'On Hold' => 2,
138    ];
139
140    /**
141     * Mappings from fee (account line) types
142     *
143     * @var array
144     */
145    protected $feeTypeMappings = [
146        'A' => 'Account',
147        'C' => 'Credit',
148        'Copie' => 'Copier Fee',
149        'F' => 'Overdue',
150        'FU' => 'Accrued Fine',
151        'L' => 'Lost Item Replacement',
152        'M' => 'Sundry',
153        'N' => 'New Card',
154        'ODUE' => 'Overdue',
155        'Res' => 'Hold Fee',
156        'HE' => 'Hold Expired',
157        'RENT' => 'Rental',
158    ];
159
160    /**
161     * Mappings from renewal block reasons
162     *
163     * @var array
164     */
165    protected $renewalBlockMappings = [
166        'too_soon' => 'ILSMessages::renewal_too_soon',
167        'auto_too_soon' => 'ILSMessages::will_auto_renew',
168        'onsite_checkout' => 'ILSMessages::special_circulation',
169        'on_reserve' => 'renew_item_requested',
170        'too_many' => 'renew_item_limit',
171        'restriction' => 'ILSMessages::renewal_block',
172        'overdue' => 'renew_item_overdue',
173        'cardlost' => 'ILSMessages::lost_card',
174        'gonenoaddress' => 'patron_status_address_missing',
175        'debarred' => 'patron_status_card_blocked',
176        'debt' => 'ILSMessages::too_much_debt',
177        'recalled' => 'ILSMessages::renewal_recalled',
178    ];
179
180    /**
181     * Permanent renewal blocks
182     *
183     * @var array
184     */
185    protected $permanentRenewalBlocks = [
186        'onsite_checkout',
187        'on_reserve',
188        'too_many',
189    ];
190
191    /**
192     * Patron status mappings
193     *
194     * @var array
195     */
196    protected $patronStatusMappings = [
197        'Hold::MaximumHoldsReached' => 'patron_status_maximum_requests',
198        'Patron::CardExpired' => 'patron_status_card_expired',
199        'Patron::DebarredOverdue' => 'patron_status_debarred_overdue',
200        'Patron::Debt' => 'patron_status_debt_limit_reached',
201        'Patron::DebtGuarantees' => 'patron_status_guarantees_debt_limit_reached',
202        'Patron::GoneNoAddress' => 'patron_status_address_missing',
203    ];
204
205    /**
206     * Item status mappings
207     *
208     * @var array
209     */
210    protected $itemStatusMappings = [
211        'Item::Held' => 'On Hold',
212        'Item::Waiting' => 'On Holdshelf',
213        'Item::Recalled' => 'Recalled',
214    ];
215
216    /**
217     * Item status mapping methods used when the item status mappings above
218     * (or in the configuration file) don't contain a direct mapping.
219     *
220     * @var array
221     */
222    protected $itemStatusMappingMethods = [
223        'Item::CheckedOut' => 'getStatusCodeItemCheckedOut',
224        'Item::Lost' => 'getStatusCodeItemNotForLoanOrLost',
225        'Item::NotForLoan' => 'getStatusCodeItemNotForLoanOrLost',
226        'Item::NotForLoanForcing' => 'getStatusCodeItemNotForLoanOrLost',
227        'Item::Transfer' => 'getStatusCodeItemTransfer',
228        'ItemType::NotForLoan' => 'getStatusCodeItemNotForLoanOrLost',
229    ];
230
231    /**
232     * Whether to display home library instead of holding library
233     *
234     * @var bool
235     */
236    protected $useHomeLibrary = false;
237
238    /**
239     * Whether to sort items by serial issue. Default is true.
240     *
241     * @var bool
242     */
243    protected $sortItemsBySerialIssue = true;
244
245    /**
246     * Constructor
247     *
248     * @param \VuFind\Date\Converter $dateConverter     Date converter object
249     * @param callable               $sessionFactory    Factory function returning
250     * SessionContainer object
251     * @param CurrencyFormatter      $currencyFormatter Currency formatter
252     */
253    public function __construct(
254        \VuFind\Date\Converter $dateConverter,
255        $sessionFactory,
256        currencyFormatter $currencyFormatter
257    ) {
258        $this->dateConverter = $dateConverter;
259        $this->sessionFactory = $sessionFactory;
260        $this->currencyFormatter = $currencyFormatter;
261    }
262
263    /**
264     * Initialize the driver.
265     *
266     * Validate configuration and perform all resource-intensive tasks needed to
267     * make the driver active.
268     *
269     * @throws ILSException
270     * @return void
271     */
272    public function init()
273    {
274        // Validate config
275        $required = ['host'];
276        foreach ($required as $current) {
277            if (!isset($this->config['Catalog'][$current])) {
278                throw new ILSException("Missing Catalog/{$current} config setting.");
279            }
280        }
281
282        $this->dontValidatePasswords
283            = !empty($this->config['Catalog']['dontValidatePasswords']);
284
285        $this->defaultPickUpLocation
286            = $this->config['Holds']['defaultPickUpLocation'] ?? '';
287        if ($this->defaultPickUpLocation === 'user-selected') {
288            $this->defaultPickUpLocation = false;
289        }
290
291        $this->allowCancelInTransit
292            = !empty($this->config['Holds']['allowCancelInTransit']);
293
294        if (!empty($this->config['StatusRankings'])) {
295            $this->statusRankings = array_merge(
296                $this->statusRankings,
297                $this->config['StatusRankings']
298            );
299        }
300
301        if (!empty($this->config['FeeTypeMappings'])) {
302            $this->feeTypeMappings = array_merge(
303                $this->feeTypeMappings,
304                $this->config['FeeTypeMappings']
305            );
306        }
307
308        if (!empty($this->config['PatronStatusMappings'])) {
309            $this->patronStatusMappings = array_merge(
310                $this->patronStatusMappings,
311                $this->config['PatronStatusMappings']
312            );
313        }
314
315        if (!empty($this->config['ItemStatusMappings'])) {
316            $this->itemStatusMappings = array_merge(
317                $this->itemStatusMappings,
318                $this->config['ItemStatusMappings']
319            );
320        }
321
322        $this->useHomeLibrary = !empty($this->config['Holdings']['useHomeLibrary']);
323
324        $this->sortItemsBySerialIssue
325            = $this->config['Holdings']['sortBySerialIssue'] ?? true;
326
327        // Init session cache for session-specific data
328        $namespace = md5($this->config['Catalog']['host']);
329        $factory = $this->sessionFactory;
330        $this->sessionCache = $factory($namespace);
331    }
332
333    /**
334     * Method to ensure uniform cache keys for cached VuFind objects.
335     *
336     * @param string|null $suffix Optional suffix that will get appended to the
337     * object class name calling getCacheKey()
338     *
339     * @return string
340     */
341    protected function getCacheKey($suffix = null)
342    {
343        return 'KohaRest' . '-' . md5($this->config['Catalog']['host'] . $suffix);
344    }
345
346    /**
347     * Get Status
348     *
349     * This is responsible for retrieving the status information of a certain
350     * record.
351     *
352     * @param string $id The record id to retrieve the holdings for
353     *
354     * @return array An associative array with the following keys:
355     * id, availability (boolean), status, location, reserve, callnumber.
356     */
357    public function getStatus($id)
358    {
359        return $this->getItemStatusesForBiblio($id);
360    }
361
362    /**
363     * Get Statuses
364     *
365     * This is responsible for retrieving the status information for a
366     * collection of records.
367     *
368     * @param array $ids The array of record ids to retrieve the status for
369     *
370     * @return mixed     An array of getStatus() return values on success.
371     */
372    public function getStatuses($ids)
373    {
374        $items = [];
375        foreach ($ids as $id) {
376            $items[] = $this->getItemStatusesForBiblio($id);
377        }
378        return $items;
379    }
380
381    /**
382     * Get Holding
383     *
384     * This is responsible for retrieving the holding information of a certain
385     * record.
386     *
387     * @param string $id      The record id to retrieve the holdings for
388     * @param array  $patron  Patron data
389     * @param array  $options Extra options
390     *
391     * @throws ILSException
392     * @return array         On success, an associative array with the following
393     * keys: id, availability (boolean), status, location, reserve, callnumber,
394     * duedate, number, barcode.
395     *
396     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
397     */
398    public function getHolding($id, array $patron = null, array $options = [])
399    {
400        return $this->getItemStatusesForBiblio($id, $patron);
401    }
402
403    /**
404     * Get Purchase History
405     *
406     * This is responsible for retrieving the acquisitions history data for the
407     * specific record (usually recently received issues of a serial).
408     *
409     * @param string $id The record id to retrieve the info for
410     *
411     * @return mixed     An array with the acquisitions data on success.
412     *
413     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
414     */
415    public function getPurchaseHistory($id)
416    {
417        return [];
418    }
419
420    /**
421     * Get New Items
422     *
423     * Retrieve the IDs of items recently added to the catalog.
424     *
425     * @param int $page    Page number of results to retrieve (counting starts at 1)
426     * @param int $limit   The size of each page of results to retrieve
427     * @param int $daysOld The maximum age of records to retrieve in days (max. 30)
428     * @param int $fundId  optional fund ID to use for limiting results (use a value
429     * returned by getFunds, or exclude for no limit); note that "fund" may be a
430     * misnomer - if funds are not an appropriate way to limit your new item
431     * results, you can return a different set of values from getFunds. The
432     * important thing is that this parameter supports an ID returned by getFunds,
433     * whatever that may mean.
434     *
435     * @return array       Associative array with 'count' and 'results' keys
436     *
437     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
438     */
439    public function getNewItems($page, $limit, $daysOld, $fundId = null)
440    {
441        return ['count' => 0, 'results' => []];
442    }
443
444    /**
445     * Get Departments
446     *
447     * @throws ILSException
448     * @return array An associative array with key = ID, value = dept. name.
449     */
450    public function getDepartments()
451    {
452        $result = $this->makeRequest('v1/contrib/kohasuomi/departments');
453        if (200 !== $result['code']) {
454            throw new ILSException('Problem with Koha REST API.');
455        }
456        $departments = [];
457        foreach ($result['data'] as $department) {
458            // Before Koha 23.11, authorized values contained authorised_value and lib_opac.
459            // From 23.11, they are 'value' and 'opac_description' (see Koha bug 32981):
460            $code = $department['value'] ?? $department['authorised_value'];
461            $description = $department['opac_description'] ?? $department['lib_opac'];
462            $departments[$code] = $description;
463        }
464        return $departments;
465    }
466
467    /**
468     * Get Instructors
469     *
470     * @throws ILSException
471     * @return array An associative array with key = ID, value = name.
472     */
473    public function getInstructors()
474    {
475        $result = $this->makeRequest('v1/contrib/kohasuomi/instructors');
476        if (200 !== $result['code']) {
477            throw new ILSException('Problem with Koha REST API.');
478        }
479        $instructors = [];
480        foreach ($result['data'] as $instructor) {
481            $name = trim(
482                ($instructor['firstname'] ?? '') . ' '
483                . ($instructor['surname'] ?? '')
484            );
485            $instructors[$instructor['patron_id']] = $name;
486        }
487        return $instructors;
488    }
489
490    /**
491     * Get Courses
492     *
493     * @throws ILSException
494     * @return array An associative array with key = ID, value = name.
495     */
496    public function getCourses()
497    {
498        $result = $this->makeRequest('v1/contrib/kohasuomi/courses');
499        if (200 !== $result['code']) {
500            throw new ILSException('Problem with Koha REST API.');
501        }
502        $courses = [];
503        foreach ($result['data'] as $course) {
504            $courses[$course['course_id']] = $course['course_name'];
505        }
506        return $courses;
507    }
508
509    /**
510     * Find Reserves
511     *
512     * Obtain information on course reserves.
513     *
514     * @param string $course ID from getCourses (empty string to match all)
515     * @param string $inst   ID from getInstructors (empty string to match all)
516     * @param string $dept   ID from getDepartments (empty string to match all)
517     *
518     * @throws ILSException
519     * @return array An array of associative arrays representing reserve items.
520     */
521    public function findReserves($course, $inst, $dept)
522    {
523        $params = [];
524        if ('' !== $course) {
525            $params['course_id'] = $course;
526        }
527        if ('' !== $inst) {
528            $params['patron_id'] = $inst;
529        }
530        if ('' !== $dept) {
531            $params['department'] = $dept;
532        }
533
534        $result = $this->makeRequest(
535            [
536                'path' => 'v1/contrib/kohasuomi/coursereserves',
537                'query' => $params,
538            ]
539        );
540        if (200 !== $result['code']) {
541            throw new ILSException('Problem with Koha REST API.');
542        }
543
544        $reserves = [];
545        foreach ($result['data'] as $reserve) {
546            $reserves[] = [
547                'BIB_ID' => $reserve['biblio_id'],
548                'COURSE_ID' => $reserve['course_id'],
549                'DEPARTMENT_ID' => $reserve['course_department'],
550                'INSTRUCTOR_ID' => $reserve['patron_id'],
551            ];
552        }
553        return $reserves;
554    }
555
556    /**
557     * Patron Login
558     *
559     * This is responsible for authenticating a patron against the catalog.
560     *
561     * @param string $username The patron username
562     * @param string $password The patron password
563     *
564     * @return mixed           Associative array of patron info on successful login,
565     * null on unsuccessful login.
566     *
567     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
568     */
569    public function patronLogin($username, $password)
570    {
571        if (empty($username)) {
572            return null;
573        }
574
575        if ($this->dontValidatePasswords) {
576            $result = $this->makeRequest(
577                [
578                    'path' => 'v1/patrons',
579                    'query' => [
580                        'userid' => $username,
581                        '_match' => 'exact',
582                    ],
583                    'method' => 'GET',
584                    'errors' => true,
585                ]
586            );
587
588            if (isset($result['data'][0])) {
589                $data = $result['data'][0];
590            } else {
591                return null;
592            }
593        } else {
594            if (empty($password)) {
595                return null;
596            }
597
598            $result = $this->makeRequest(
599                [
600                    'path' => 'v1/contrib/kohasuomi/auth/patrons/validation',
601                    'json' => ['userid' => $username, 'password' => $password],
602                    'method' => 'POST',
603                    'errors' => true,
604                ]
605            );
606
607            if (isset($result['data'])) {
608                $data = $result['data'];
609            } else {
610                return null;
611            }
612        }
613
614        if (401 === $result['code'] || 403 === $result['code']) {
615            return null;
616        }
617        if (200 !== $result['code']) {
618            throw new ILSException('Problem with Koha REST API.');
619        }
620
621        return [
622            'id' => $data['patron_id'],
623            'firstname' => $data['firstname'],
624            'lastname' => $data['surname'],
625            'cat_username' => $username,
626            'cat_password' => (string)$password,
627            'email' => $data['email'],
628            'major' => null,
629            'college' => null,
630            'home_library' => $data['library_id'],
631        ];
632    }
633
634    /**
635     * Check whether the patron is blocked from placing requests (holds/ILL/SRR).
636     *
637     * @param array $patron Patron data from patronLogin().
638     *
639     * @return mixed A boolean false if no blocks are in place and an array
640     * of block reasons if blocks are in place
641     */
642    public function getRequestBlocks($patron)
643    {
644        return $this->getPatronBlocks($patron);
645    }
646
647    /**
648     * Check whether the patron has any blocks on their account.
649     *
650     * @param array $patron Patron data from patronLogin().
651     *
652     * @return mixed A boolean false if no blocks are in place and an array
653     * of block reasons if blocks are in place
654     */
655    public function getAccountBlocks($patron)
656    {
657        return $this->getPatronBlocks($patron);
658    }
659
660    /**
661     * Get Patron Profile
662     *
663     * This is responsible for retrieving the profile for a specific patron.
664     *
665     * @param array $patron The patron array
666     *
667     * @throws ILSException
668     * @return array        Array of the patron's profile data on success.
669     */
670    public function getMyProfile($patron)
671    {
672        $result = $this->makeRequest(['v1', 'patrons', $patron['id']]);
673
674        if (200 !== $result['code']) {
675            throw new ILSException('Problem with Koha REST API.');
676        }
677
678        $result = $result['data'];
679        return [
680            'firstname' => $result['firstname'],
681            'lastname' => $result['surname'],
682            'phone' => $result['phone'],
683            'mobile_phone' => $result['mobile'],
684            'email' => $result['email'],
685            'address1' => $result['address'],
686            'address2' => $result['address2'],
687            'zip' => $result['postal_code'],
688            'city' => $result['city'],
689            'country' => $result['country'],
690            'expiration_date' => $this->convertDate($result['expiry_date'] ?? null),
691            'birthdate' => $result['date_of_birth'] ?? '',
692        ];
693    }
694
695    /**
696     * Get Patron Transactions
697     *
698     * This is responsible for retrieving all transactions (i.e. checked out items)
699     * by a specific patron.
700     *
701     * @param array $patron The patron array from patronLogin
702     * @param array $params Parameters
703     *
704     * @throws DateException
705     * @throws ILSException
706     * @return array        Array of the patron's transactions on success.
707     */
708    public function getMyTransactions($patron, $params = [])
709    {
710        return $this->getTransactions($patron, $params, false);
711    }
712
713    /**
714     * Get Renew Details
715     *
716     * @param array $checkOutDetails An array of item data
717     *
718     * @return string Data for use in a form field
719     */
720    public function getRenewDetails($checkOutDetails)
721    {
722        return $checkOutDetails['checkout_id'] . '|' . $checkOutDetails['item_id'];
723    }
724
725    /**
726     * Renew My Items
727     *
728     * Function for attempting to renew a patron's items. The data in
729     * $renewDetails['details'] is determined by getRenewDetails().
730     *
731     * @param array $renewDetails An array of data required for renewing items
732     * including the Patron ID and an array of renewal IDS
733     *
734     * @return array              An array of renewal information keyed by item ID
735     */
736    public function renewMyItems($renewDetails)
737    {
738        $finalResult = ['details' => []];
739
740        foreach ($renewDetails['details'] as $details) {
741            [$checkoutId, $itemId] = explode('|', $details);
742            $result = $this->makeRequest(
743                [
744                    'path' => ['v1', 'checkouts', $checkoutId, 'renewal'],
745                    'method' => 'POST',
746                ]
747            );
748            if (201 === $result['code']) {
749                $newDate
750                    = $this->convertDate($result['data']['due_date'] ?? null, true);
751                $finalResult['details'][$itemId] = [
752                    'item_id' => $itemId,
753                    'success' => true,
754                    'new_date' => $newDate,
755                ];
756            } else {
757                $finalResult['details'][$itemId] = [
758                    'item_id' => $itemId,
759                    'success' => false,
760                ];
761            }
762        }
763        return $finalResult;
764    }
765
766    /**
767     * Get Patron Transaction History
768     *
769     * This is responsible for retrieving all historical transactions
770     * (i.e. checked out items)
771     * by a specific patron.
772     *
773     * @param array $patron The patron array from patronLogin
774     * @param array $params Parameters
775     *
776     * @throws DateException
777     * @throws ILSException
778     * @return array        Array of the patron's transactions on success.
779     */
780    public function getMyTransactionHistory($patron, $params)
781    {
782        return $this->getTransactions($patron, $params, true);
783    }
784
785    /**
786     * Purge Patron Transaction History
787     *
788     * @param array  $patron The patron array from patronLogin
789     * @param ?array $ids    IDs to purge, or null for all
790     *
791     * @throws ILSException
792     * @return array Associative array of the results
793     */
794    public function purgeTransactionHistory(array $patron, ?array $ids): array
795    {
796        if (null !== $ids) {
797            throw new ILSException('Unsupported function');
798        }
799        $result = $this->makeRequest(
800            [
801                'path' => [
802                    'v1', 'contrib', 'kohasuomi', 'patrons', $patron['id'],
803                    'checkouts', 'history',
804                ],
805                'method' => 'DELETE',
806                'errors' => true,
807            ]
808        );
809        if (!in_array($result['code'], [200, 202, 204])) {
810            return  [
811                'success' => false,
812                'status' => 'An error has occurred',
813                'sys_message' => $result['data']['error'] ?? $result['code'],
814            ];
815        }
816
817        return [
818            'success' => true,
819            'status' => 'loan_history_all_purged',
820            'sys_message' => '',
821        ];
822    }
823
824    /**
825     * Get Patron Holds
826     *
827     * This is responsible for retrieving all holds by a specific patron.
828     *
829     * @param array $patron The patron array from patronLogin
830     *
831     * @throws DateException
832     * @throws ILSException
833     * @return array        Array of the patron's holds on success.
834     */
835    public function getMyHolds($patron)
836    {
837        $result = $this->makeRequest(
838            [
839                'path' => 'v1/holds',
840                'query' => [
841                    'patron_id' => $patron['id'],
842                    '_match' => 'exact',
843                    '_per_page' => -1,
844                ],
845            ]
846        );
847
848        $holds = [];
849        foreach ($result['data'] as $entry) {
850            $biblio = $this->getBiblio($entry['biblio_id']);
851            $frozen = !empty($entry['suspended']);
852            $volume = '';
853            if ($entry['item_id'] ?? null) {
854                $item = $this->getItem($entry['item_id']);
855                $volume = $item['serial_issue_number'];
856            }
857            $available = !empty($entry['waiting_date']);
858            $inTransit = !empty($entry['status']) && $entry['status'] == 'T';
859            $requestId = $entry['hold_id'];
860            $cancelDetails
861                = ($available || ($inTransit && !$this->allowCancelInTransit))
862                ? ''
863                : $requestId;
864            $updateDetails = ($available || $inTransit) ? '' : $requestId;
865            // Note: Expiration date is the last interest date until the hold becomes
866            // available for pickup. Then it becomes the last pickup date.
867            $expirationDate = $this->convertDate($entry['expiration_date']);
868            $holds[] = [
869                'id' => $entry['biblio_id'],
870                'item_id' => $entry['hold_id'],
871                'reqnum' => $requestId,
872                'location' => $this->getLibraryName(
873                    $entry['pickup_library_id'] ?? null
874                ),
875                'create' => $this->convertDate($entry['hold_date'] ?? null),
876                'expire' => $available ? null : $expirationDate,
877                'position' => $entry['priority'],
878                'available' => $available,
879                'last_pickup_date' => $available ? $expirationDate : null,
880                'frozen' => $frozen,
881                'frozenThrough' => $frozen
882                    ? $this->convertDate($entry['suspended_until'] ?? null) : null,
883                'in_transit' => $inTransit,
884                'title' => $this->getBiblioTitle($biblio),
885                'isbn' => $biblio['isbn'] ?? '',
886                'issn' => $biblio['issn'] ?? '',
887                'publication_year' => $biblio['copyright_date']
888                    ?? $biblio['publication_year'] ?? '',
889                'volume' => $volume,
890                'cancel_details' => $cancelDetails,
891                'updateDetails' => $updateDetails,
892            ];
893        }
894
895        return $holds;
896    }
897
898    /**
899     * Cancel Holds
900     *
901     * Attempts to Cancel a hold. The data in $cancelDetails['details'] is taken from
902     * holds' cancel_details field.
903     *
904     * @param array $cancelDetails An array of item and patron data
905     *
906     * @return array               An array of data on each request including
907     * whether or not it was successful and a system message (if available)
908     */
909    public function cancelHolds($cancelDetails)
910    {
911        $details = $cancelDetails['details'];
912        $count = 0;
913        $response = [];
914
915        foreach ($details as $holdId) {
916            $result = $this->makeRequest(
917                [
918                    'path' => ['v1', 'holds', $holdId],
919                    'method' => 'DELETE',
920                    'errors' => true,
921                ]
922            );
923
924            if (200 === $result['code'] || 204 === $result['code']) {
925                $response[$holdId] = [
926                    'success' => true,
927                    'status' => 'hold_cancel_success',
928                ];
929                ++$count;
930            } else {
931                $response[$holdId] = [
932                    'success' => false,
933                    'status' => 'hold_cancel_fail',
934                    'sysMessage' => false,
935                ];
936            }
937        }
938        return ['count' => $count, 'items' => $response];
939    }
940
941    /**
942     * Get Pick Up Locations
943     *
944     * This is responsible for gettting a list of valid library locations for
945     * holds / recall retrieval
946     *
947     * @param array $patron      Patron information returned by the patronLogin
948     * method.
949     * @param array $holdDetails Optional array, only passed in when getting a list
950     * in the context of placing or editing a hold. When placing a hold, it contains
951     * most of the same values passed to placeHold, minus the patron data. When
952     * editing a hold it contains all the hold information returned by getMyHolds.
953     * May be used to limit the pickup options or may be ignored. The driver must
954     * not add new options to the return array based on this data or other areas of
955     * VuFind may behave incorrectly.
956     *
957     * @throws ILSException
958     * @return array        An array of associative arrays with locationID and
959     * locationDisplay keys
960     *
961     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
962     */
963    public function getPickUpLocations($patron = false, $holdDetails = null)
964    {
965        $bibId = $holdDetails['id'] ?? null;
966        $itemId = $holdDetails['item_id'] ?? false;
967        $requestId = $holdDetails['reqnum'] ?? false;
968        $requestType
969            = array_key_exists('StorageRetrievalRequest', $holdDetails ?? [])
970                ? 'StorageRetrievalRequests' : 'Holds';
971        $included = null;
972        if ($bibId && 'Holds' === $requestType) {
973            // Collect library codes that are to be included
974            $level = !empty($holdDetails['level']) ? $holdDetails['level'] : 'title';
975            if ('copy' === $level && false === $itemId) {
976                return [];
977            }
978            if ('copy' === $level) {
979                $result = $this->makeRequest(
980                    [
981                        'path' => [
982                            'v1', 'contrib', 'kohasuomi', 'availability', 'items',
983                            $itemId, 'hold',
984                        ],
985                        'query' => [
986                            'patron_id' => (int)$patron['id'],
987                            'query_pickup_locations' => 1,
988                        ],
989                    ]
990                );
991                if (empty($result['data'])) {
992                    return [];
993                }
994                $notes = $result['data']['availability']['notes'] ?? [];
995                $included = $notes['Item::PickupLocations']['to_libraries'] ?? [];
996            } else {
997                $result = $this->makeRequest(
998                    [
999                        'path' => [
1000                            'v1', 'contrib', 'kohasuomi', 'availability', 'biblios',
1001                            $bibId, 'hold',
1002                        ],
1003                        'query' => [
1004                            'patron_id' => (int)$patron['id'],
1005                            'query_pickup_locations' => 1,
1006                            'ignore_patron_holds' => $requestId ? 1 : 0,
1007                        ],
1008                    ]
1009                );
1010                if (empty($result['data'])) {
1011                    return [];
1012                }
1013                $notes = $result['data']['availability']['notes'] ?? [];
1014                $included = $notes['Biblio::PickupLocations']['to_libraries'] ?? [];
1015            }
1016        }
1017
1018        $excluded = isset($this->config['Holds']['excludePickupLocations'])
1019            ? explode(':', $this->config['Holds']['excludePickupLocations']) : [];
1020        $locations = [];
1021        foreach ($this->getLibraries() as $library) {
1022            $code = $library['library_id'];
1023            if (
1024                (null === $included && !$library['pickup_location'])
1025                || in_array($code, $excluded)
1026                || (null !== $included && !in_array($code, $included))
1027            ) {
1028                continue;
1029            }
1030            $locations[] = [
1031                'locationID' => $code,
1032                'locationDisplay' => $library['name'],
1033            ];
1034        }
1035
1036        // Do we need to sort pickup locations? If the setting is false, don't
1037        // bother doing any more work. If it's not set at all, default to
1038        // alphabetical order.
1039        $orderSetting = $this->config['Holds']['pickUpLocationOrder'] ?? 'default';
1040        if (count($locations) > 1 && !empty($orderSetting)) {
1041            $locationOrder = $orderSetting === 'default'
1042                ? [] : array_flip(explode(':', $orderSetting));
1043            $sortFunction = function ($a, $b) use ($locationOrder) {
1044                $aLoc = $a['locationID'];
1045                $bLoc = $b['locationID'];
1046                if (isset($locationOrder[$aLoc])) {
1047                    if (isset($locationOrder[$bLoc])) {
1048                        return $locationOrder[$aLoc] - $locationOrder[$bLoc];
1049                    }
1050                    return -1;
1051                }
1052                if (isset($locationOrder[$bLoc])) {
1053                    return 1;
1054                }
1055                return $this->getSorter()->compare(
1056                    $a['locationDisplay'],
1057                    $b['locationDisplay']
1058                );
1059            };
1060            usort($locations, $sortFunction);
1061        }
1062
1063        return $locations;
1064    }
1065
1066    /**
1067     * Get Default Pick Up Location
1068     *
1069     * Returns the default pick up location
1070     *
1071     * @param array $patron      Patron information returned by the patronLogin
1072     * method.
1073     * @param array $holdDetails Optional array, only passed in when getting a list
1074     * in the context of placing a hold; contains most of the same values passed to
1075     * placeHold, minus the patron data. May be used to limit the pickup options
1076     * or may be ignored.
1077     *
1078     * @return false|string      The default pickup location for the patron or false
1079     * if the user has to choose.
1080     *
1081     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1082     */
1083    public function getDefaultPickUpLocation($patron = false, $holdDetails = null)
1084    {
1085        return $this->defaultPickUpLocation;
1086    }
1087
1088    /**
1089     * Check if request is valid
1090     *
1091     * This is responsible for determining if an item is requestable
1092     *
1093     * @param string $id     The Bib ID
1094     * @param array  $data   An Array of item data
1095     * @param array  $patron An array of patron data
1096     *
1097     * @return mixed An array of data on the request including
1098     * whether or not it is valid and a status message. Alternatively a boolean
1099     * true if request is valid, false if not.
1100     */
1101    public function checkRequestIsValid($id, $data, $patron)
1102    {
1103        if ($this->getPatronBlocks($patron)) {
1104            return false;
1105        }
1106        $level = $data['level'] ?? 'copy';
1107        if ('title' === $level) {
1108            $result = $this->makeRequest(
1109                [
1110                    'path' => [
1111                        'v1', 'contrib', 'kohasuomi', 'availability', 'biblios', $id,
1112                        'hold',
1113                    ],
1114                    'query' => ['patron_id' => $patron['id']],
1115                ]
1116            );
1117            if (!empty($result['data']['availability']['available'])) {
1118                return [
1119                    'valid' => true,
1120                    'status' => 'title_hold_place',
1121                ];
1122            }
1123            return [
1124                'valid' => false,
1125                'status' => $this->getHoldBlockReason($result['data']),
1126            ];
1127        }
1128
1129        // Check if we have an item id:
1130        if (empty($data['item_id'])) {
1131            return false;
1132        }
1133
1134        $result = $this->makeRequest(
1135            [
1136                'path' => [
1137                    'v1', 'contrib', 'kohasuomi', 'availability', 'items',
1138                    $data['item_id'], 'hold',
1139                ],
1140                'query' => ['patron_id' => $patron['id']],
1141            ]
1142        );
1143        if (!empty($result['data']['availability']['available'])) {
1144            return [
1145                'valid' => true,
1146                'status' => 'hold_place',
1147            ];
1148        }
1149        return [
1150            'valid' => false,
1151            'status' => $this->getHoldBlockReason($result['data']),
1152        ];
1153    }
1154
1155    /**
1156     * Place Hold
1157     *
1158     * Attempts to place a hold or recall on a particular item and returns
1159     * an array with result details or throws an exception on failure of support
1160     * classes
1161     *
1162     * @param array $holdDetails An array of item and patron data
1163     *
1164     * @throws ILSException
1165     * @return mixed An array of data on the request including
1166     * whether or not it was successful and a system message (if available)
1167     */
1168    public function placeHold($holdDetails)
1169    {
1170        $patron = $holdDetails['patron'];
1171        $level = isset($holdDetails['level']) && !empty($holdDetails['level'])
1172            ? $holdDetails['level'] : 'copy';
1173        $pickUpLocation = !empty($holdDetails['pickUpLocation'])
1174            ? $holdDetails['pickUpLocation'] : $this->defaultPickUpLocation;
1175        $itemId = $holdDetails['item_id'] ?? false;
1176        $comment = $holdDetails['comment'] ?? '';
1177        $bibId = $holdDetails['id'];
1178
1179        if ($level == 'copy' && empty($itemId)) {
1180            throw new ILSException("Hold level is 'copy', but item ID is empty");
1181        }
1182
1183        // Make sure pickup location is valid
1184        if (!$this->pickUpLocationIsValid($pickUpLocation, $patron, $holdDetails)) {
1185            return $this->holdError('hold_invalid_pickup');
1186        }
1187
1188        $request = [
1189            'biblio_id' => (int)$bibId,
1190            'patron_id' => (int)$patron['id'],
1191            'pickup_library_id' => $pickUpLocation,
1192            'notes' => $comment,
1193            'expiration_date' => !empty($holdDetails['requiredByTS'])
1194                        ? date('Y-m-d', $holdDetails['requiredByTS'])
1195                        : null,
1196        ];
1197        if ($level == 'copy') {
1198            $request['item_id'] = (int)$itemId;
1199        }
1200
1201        $result = $this->makeRequest(
1202            [
1203                'path' => 'v1/holds',
1204                'json' => $request,
1205                'method' => 'POST',
1206                'errors' => true,
1207            ]
1208        );
1209
1210        if ($result['code'] >= 300) {
1211            return $this->holdError($result['data']['error'] ?? 'hold_error_fail');
1212        }
1213
1214        if ($holdDetails['startDateTS']) {
1215            $holdId = $result['data']['hold_id'];
1216            // Suspend until the previous day from start date:
1217            $request = [
1218                'suspended_until' => \DateTime::createFromFormat(
1219                    'U',
1220                    $holdDetails['startDateTS']
1221                )->modify('-1 DAY')->format('Y-m-d') . ' 23:59:59',
1222            ];
1223            $result = $this->makeRequest(
1224                [
1225                    'path' => ['v1', 'holds', $holdId],
1226                    'json' => $request,
1227                    'method' => 'PUT',
1228                    'errors' => true,
1229                ]
1230            );
1231            if ($result['code'] >= 300) {
1232                // Report a success since the hold was created, but include a message
1233                // about the modification failure:
1234                return [
1235                    'success' => true,
1236                    'warningMessage' => 'hold_error_update_failed',
1237                ];
1238            }
1239        }
1240
1241        return ['success' => true];
1242    }
1243
1244    /**
1245     * Update holds
1246     *
1247     * This is responsible for changing the status of hold requests
1248     *
1249     * @param array $holdsDetails The details identifying the holds
1250     * @param array $fields       An associative array of fields to be updated
1251     * @param array $patron       Patron array
1252     *
1253     * @return array Associative array of the results
1254     */
1255    public function updateHolds(
1256        array $holdsDetails,
1257        array $fields,
1258        array $patron
1259    ): array {
1260        $results = [];
1261        foreach ($holdsDetails as $requestId) {
1262            $updateFields = [];
1263            // Suspension (bool) has its own endpoint, so we need to distinguish
1264            // between the cases
1265            if (isset($fields['frozen'])) {
1266                if ($fields['frozen']) {
1267                    if (isset($fields['frozenThrough'])) {
1268                        // Convert the date to end of day in RFC3339 format. Note
1269                        // that as of May 2022 Koha only uses the date part and
1270                        // ignores time, but requires a valid RFC3339 date+time.
1271                        $date = $this->dateConverter->convertToDateTime(
1272                            'U',
1273                            $fields['frozenThroughTS']
1274                        );
1275                        $date->setTime(23, 59, 59, 999);
1276                        $updateFields['suspended_until']
1277                            = $date->format($date::RFC3339);
1278                        $result = false;
1279                    } else {
1280                        $result = $this->makeRequest(
1281                            [
1282                                'path' => ['v1', 'holds', $requestId, 'suspension'],
1283                                'method' => 'POST',
1284                                'json' => new \stdClass(), // For empty JSON object
1285                                'errors' => true,
1286                            ]
1287                        );
1288                    }
1289                } else {
1290                    $result = $this->makeRequest(
1291                        [
1292                            'path' => ['v1', 'holds', $requestId, 'suspension'],
1293                            'method' => 'DELETE',
1294                            'json' => new \stdClass(), // For empty JSON object
1295                            'errors' => true,
1296                        ]
1297                    );
1298                }
1299                if ($result && $result['code'] >= 300) {
1300                    $results[$requestId]['status']
1301                        = $result['data']['error'] ?? 'hold_error_update_failed';
1302                }
1303            }
1304            if (empty($results[$requestId]['errors'])) {
1305                if (isset($fields['pickUpLocation'])) {
1306                    $updateFields['pickup_library_id'] = $fields['pickUpLocation'];
1307                }
1308                if ($updateFields) {
1309                    $result = $this->makeRequest(
1310                        [
1311                            'path' => ['v1', 'holds', $requestId],
1312                            'method' => 'PUT',
1313                            'json' => $updateFields,
1314                            'errors' => true,
1315                        ]
1316                    );
1317                    if ($result['code'] >= 300) {
1318                        $results[$requestId]['status']
1319                            = $result['data']['error'] ?? 'hold_error_update_failed';
1320                    }
1321                }
1322            }
1323
1324            $results[$requestId]['success'] = empty($results[$requestId]['status']);
1325        }
1326
1327        return $results;
1328    }
1329
1330    /**
1331     * Get Patron Storage Retrieval Requests
1332     *
1333     * This is responsible for retrieving all article requests by a specific patron.
1334     *
1335     * @param array $patron The patron array from patronLogin
1336     *
1337     * @return array        Array of the patron's storage retrieval requests.
1338     */
1339    public function getMyStorageRetrievalRequests($patron)
1340    {
1341        $result = $this->makeRequest(
1342            [
1343                'v1', 'contrib', 'kohasuomi', 'patrons', $patron['id'],
1344                'articlerequests',
1345            ]
1346        );
1347        if (empty($result)) {
1348            return [];
1349        }
1350        $requests = [];
1351        foreach ($result['data'] as $entry) {
1352            // Article requests don't yet have a unified API mapping in Koha.
1353            // Try to take into account existing and predicted field names.
1354            $bibId = $entry['biblio_id'] ?? $entry['biblionumber'] ?? null;
1355            $itemId = $entry['item_id'] ?? $entry['itemnumber'] ?? null;
1356            $location = $entry['library_id'] ?? $entry['branchcode'] ?? null;
1357            $title = '';
1358            $volume = '';
1359            if ($itemId) {
1360                $item = $this->getItem($itemId);
1361                $bibId = $item['biblio_id'];
1362                $volume = $item['serial_issue_number'];
1363            }
1364            if (!empty($bibId)) {
1365                $bib = $this->getBiblio($bibId);
1366                $title = $this->getBiblioTitle($bib);
1367            }
1368            $requests[] = [
1369                'id' => $bibId,
1370                'item_id' => $entry['id'],
1371                'location' => $location,
1372                'create' => $this->convertDate($entry['created_on']),
1373                'available' => $entry['status'] === 'COMPLETED',
1374                'title' => $title,
1375                'volume' => $volume,
1376            ];
1377        }
1378        return $requests;
1379    }
1380
1381    /**
1382     * Get Cancel Storage Retrieval Request (article request) Details
1383     *
1384     * @param array $details An array of item data
1385     * @param array $patron  Patron information from patronLogin
1386     *
1387     * @return string Data for use in a form field
1388     *
1389     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1390     */
1391    public function getCancelStorageRetrievalRequestDetails($details, $patron)
1392    {
1393        return $details['item_id'];
1394    }
1395
1396    /**
1397     * Cancel Storage Retrieval Requests (article requests)
1398     *
1399     * Attempts to Cancel an article request on a particular item. The
1400     * data in $cancelDetails['details'] is determined by
1401     * getCancelStorageRetrievalRequestDetails().
1402     *
1403     * @param array $cancelDetails An array of item and patron data
1404     *
1405     * @return array               An array of data on each request including
1406     * whether or not it was successful and a system message (if available)
1407     */
1408    public function cancelStorageRetrievalRequests($cancelDetails)
1409    {
1410        $details = $cancelDetails['details'];
1411        $patron = $cancelDetails['patron'];
1412        $count = 0;
1413        $response = [];
1414
1415        foreach ($details as $id) {
1416            $result = $this->makeRequest(
1417                [
1418                    'path' => [
1419                        'v1', 'contrib', 'kohasuomi', 'patrons', $patron['id'],
1420                        'articlerequests', $id,
1421                    ],
1422                    'method' => 'DELETE',
1423                    'errors' => true,
1424                ]
1425            );
1426
1427            if (200 !== $result['code']) {
1428                $response[$id] = [
1429                    'success' => false,
1430                    'status' => 'storage_retrieval_request_cancel_fail',
1431                    'sysMessage' => false,
1432                ];
1433            } else {
1434                $response[$id] = [
1435                    'success' => true,
1436                    'status' => 'storage_retrieval_request_cancel_success',
1437                ];
1438                ++$count;
1439            }
1440        }
1441        return ['count' => $count, 'items' => $response];
1442    }
1443
1444    /**
1445     * Check if storage retrieval request is valid
1446     *
1447     * This is responsible for determining if an item is requestable
1448     *
1449     * @param string $id     The Bib ID
1450     * @param array  $data   An Array of item data
1451     * @param array  $patron An array of patron data
1452     *
1453     * @return bool True if request is valid, false if not
1454     */
1455    public function checkStorageRetrievalRequestIsValid($id, $data, $patron)
1456    {
1457        if (
1458            !isset($this->config['StorageRetrievalRequests'])
1459            || $this->getPatronBlocks($patron)
1460        ) {
1461            return false;
1462        }
1463
1464        $level = $data['level'] ?? 'copy';
1465
1466        if ('title' === $level) {
1467            $result = $this->makeRequest(
1468                [
1469                    'path' => [
1470                        'v1', 'contrib', 'kohasuomi', 'availability', 'biblios', $id,
1471                        'articlerequest',
1472                    ],
1473                    'query' => ['patron_id' => $patron['id']],
1474                ]
1475            );
1476        } else {
1477            $result = $this->makeRequest(
1478                [
1479                    'path' => [
1480                        'v1', 'contrib', 'kohasuomi', 'availability', 'items',
1481                        $data['item_id'], 'articlerequest',
1482                    ],
1483                    'query' => ['patron_id' => $patron['id']],
1484                ]
1485            );
1486        }
1487        return !empty($result['data']['availability']['available']);
1488    }
1489
1490    /**
1491     * Place Storage Retrieval Request (Call Slip)
1492     *
1493     * Attempts to place a call slip request on a particular item and returns
1494     * an array with result details
1495     *
1496     * @param array $details An array of item and patron data
1497     *
1498     * @return mixed An array of data on the request including
1499     * whether or not it was successful and a system message (if available)
1500     */
1501    public function placeStorageRetrievalRequest($details)
1502    {
1503        $patron = $details['patron'];
1504        $level = $details['level'] ?? 'copy';
1505        $pickUpLocation = $details['pickUpLocation'] ?? null;
1506        $itemId = $details['item_id'] ?? false;
1507        $comment = $details['comment'] ?? '';
1508        $bibId = $details['id'];
1509
1510        if ('copy' === $level && empty($itemId)) {
1511            throw new ILSException("Request level is 'copy', but item ID is empty");
1512        }
1513
1514        // Make sure pickup location is valid
1515        if (
1516            null !== $pickUpLocation
1517            && !$this->pickUpLocationIsValid($pickUpLocation, $patron, $details)
1518        ) {
1519            return [
1520                'success' => false,
1521                'sysMessage' => 'storage_retrieval_request_invalid_pickup',
1522            ];
1523        }
1524
1525        $request = [
1526            'biblio_id' => (int)$bibId,
1527            'pickup_library_id' => $pickUpLocation,
1528            'notes' => $comment,
1529            'volume' => $details['volume'] ?? '',
1530            'issue' => $details['issue'] ?? '',
1531            'date' => $details['year'] ?? '',
1532        ];
1533        if ($level == 'copy') {
1534            $request['item_id'] = (int)$itemId;
1535        }
1536
1537        $result = $this->makeRequest(
1538            [
1539                'path' => [
1540                    'v1', 'contrib', 'kohasuomi', 'patrons', $patron['id'],
1541                    'articlerequests',
1542                ],
1543                'json' => $request,
1544                'method' => 'POST',
1545                'errors' => true,
1546            ]
1547        );
1548
1549        if ($result['code'] >= 300) {
1550            $message = $result['data']['error']
1551                ?? 'storage_retrieval_request_error_fail';
1552            return [
1553                'success' => false,
1554                'sysMessage' => $message,
1555            ];
1556        }
1557        return [
1558            'success' => true,
1559            'status' => 'storage_retrieval_request_place_success',
1560        ];
1561    }
1562
1563    /**
1564     * Get Patron Fines
1565     *
1566     * This is responsible for retrieving all fines by a specific patron.
1567     *
1568     * @param array $patron The patron array from patronLogin
1569     *
1570     * @throws DateException
1571     * @throws ILSException
1572     * @return array        Array of the patron's fines on success.
1573     */
1574    public function getMyFines($patron)
1575    {
1576        // TODO: Make this use X-Koha-Embed when the endpoint allows
1577        $result = $this->makeRequest(['v1', 'patrons', $patron['id'], 'account']);
1578
1579        $fines = [];
1580        foreach ($result['data']['outstanding_debits']['lines'] ?? [] as $entry) {
1581            $bibId = null;
1582            if (!empty($entry['item_id'])) {
1583                $item = $this->getItem($entry['item_id']);
1584                if (!empty($item['biblio_id'])) {
1585                    $bibId = $item['biblio_id'];
1586                }
1587            }
1588            $type = trim($entry['debit_type']);
1589            $type = $this->translate($this->feeTypeMappings[$type] ?? $type);
1590            $description = trim($entry['description']);
1591            if ($description !== $type) {
1592                $type .= " - $description";
1593            }
1594            $fine = [
1595                'amount' => $entry['amount'] * 100,
1596                'balance' => $entry['amount_outstanding'] * 100,
1597                'fine' => $type,
1598                'createdate' => $this->convertDate($entry['date'] ?? null),
1599                'checkout' => '',
1600            ];
1601            if (null !== $bibId) {
1602                $fine['id'] = $bibId;
1603            }
1604            $fines[] = $fine;
1605        }
1606        return $fines;
1607    }
1608
1609    /**
1610     * Change Password
1611     *
1612     * Attempts to change patron password (PIN code)
1613     *
1614     * @param array $details An array of patron id and old and new password:
1615     *
1616     * 'patron'      The patron array from patronLogin
1617     * 'oldPassword' Old password
1618     * 'newPassword' New password
1619     *
1620     * @return array An array of data on the request including
1621     * whether or not it was successful and a system message (if available)
1622     */
1623    public function changePassword($details)
1624    {
1625        $patron = $details['patron'];
1626        $request = [
1627            'password' => $details['newPassword'],
1628            'password_2' => $details['newPassword'],
1629        ];
1630
1631        $result = $this->makeRequest(
1632            [
1633                'path' => ['v1', 'patrons', $patron['id'], 'password'],
1634                'json' => $request,
1635                'method' => 'POST',
1636                'errors' => true,
1637            ]
1638        );
1639
1640        if (200 !== $result['code']) {
1641            if (400 === $result['code']) {
1642                $message = 'password_error_invalid';
1643            } else {
1644                $message = 'An error has occurred';
1645            }
1646            return [
1647                'success' => false, 'status' => $message,
1648            ];
1649        }
1650        return ['success' => true, 'status' => 'change_password_ok'];
1651    }
1652
1653    /**
1654     * Provide an array of URL data (in the same format returned by the record
1655     * driver's getURLs method) for the specified bibliographic record.
1656     *
1657     * @param string $id Bibliographic record ID
1658     *
1659     * @return array
1660     */
1661    public function getUrlsForRecord(string $id): array
1662    {
1663        $links = [];
1664        $opacUrl = $this->config['Catalog']['opacURL'] ?? false;
1665        if ($opacUrl) {
1666            $url = strstr($opacUrl, '%%id%%') === false
1667                ? $opacUrl . urlencode($id)
1668                : str_replace('%%id%%', urlencode($id), $opacUrl);
1669            $desc = $this->translate('view_in_opac');
1670            $links[] = compact('url', 'desc');
1671        }
1672        return $links;
1673    }
1674
1675    /**
1676     * Public Function which retrieves renew, hold and cancel settings from the
1677     * driver ini file.
1678     *
1679     * @param string $function The name of the feature to be checked
1680     * @param array  $params   Optional feature-specific parameters (array)
1681     *
1682     * @return array An array with key-value pairs.
1683     *
1684     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1685     */
1686    public function getConfig($function, $params = [])
1687    {
1688        if ('getMyTransactionHistory' === $function) {
1689            if (empty($this->config['TransactionHistory']['enabled'])) {
1690                return false;
1691            }
1692            $limit = $this->config['TransactionHistory']['max_page_size'] ?? 100;
1693            return [
1694                'max_results' => $limit,
1695                'sort' => [
1696                    '-checkout_date' => 'sort_checkout_date_desc',
1697                    '+checkout_date' => 'sort_checkout_date_asc',
1698                    '-checkin_date' => 'sort_return_date_desc',
1699                    '+checkin_date' => 'sort_return_date_asc',
1700                    '-due_date' => 'sort_due_date_desc',
1701                    '+due_date' => 'sort_due_date_asc',
1702                    '+title' => 'sort_title',
1703                ],
1704                'default_sort' => '-checkout_date',
1705                'purge_all' => $this->config['TransactionHistory']['purgeAll'] ?? true,
1706            ];
1707        } elseif ('getMyTransactions' === $function) {
1708            $limit = $this->config['Loans']['max_page_size'] ?? 100;
1709            return [
1710                'max_results' => $limit,
1711                'sort' => [
1712                    '-checkout_date' => 'sort_checkout_date_desc',
1713                    '+checkout_date' => 'sort_checkout_date_asc',
1714                    '-due_date' => 'sort_due_date_desc',
1715                    '+due_date' => 'sort_due_date_asc',
1716                    '+title' => 'sort_title',
1717                ],
1718                'default_sort' => '+due_date',
1719            ];
1720        }
1721
1722        return $this->config[$function] ?? false;
1723    }
1724
1725    /**
1726     * Helper method to determine whether or not a certain method can be
1727     * called on this driver. Required method for any smart drivers.
1728     *
1729     * @param string $method The name of the called method.
1730     * @param array  $params Array of passed parameters
1731     *
1732     * @return bool True if the method can be called with the given parameters,
1733     * false otherwise.
1734     *
1735     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1736     */
1737    public function supportsMethod($method, $params)
1738    {
1739        // Special case: change password is only available if properly configured.
1740        if ($method == 'changePassword') {
1741            return isset($this->config['changePassword']);
1742        }
1743        return is_callable([$this, $method]);
1744    }
1745
1746    /**
1747     * Create a HTTP client
1748     *
1749     * @param string $url Request URL
1750     *
1751     * @return \Laminas\Http\Client
1752     */
1753    protected function createHttpClient($url)
1754    {
1755        $client = $this->httpService->createClient($url);
1756
1757        if (
1758            isset($this->config['Http']['ssl_verify_peer_name'])
1759            && !$this->config['Http']['ssl_verify_peer_name']
1760        ) {
1761            $adapter = $client->getAdapter();
1762            if ($adapter instanceof \Laminas\Http\Client\Adapter\Socket) {
1763                $context = $adapter->getStreamContext();
1764                $res = stream_context_set_option(
1765                    $context,
1766                    'ssl',
1767                    'verify_peer_name',
1768                    false
1769                );
1770                if (!$res) {
1771                    throw new \Exception('Unable to set sslverifypeername option');
1772                }
1773            } elseif ($adapter instanceof \Laminas\Http\Client\Adapter\Curl) {
1774                $adapter->setCurlOption(CURLOPT_SSL_VERIFYHOST, false);
1775            }
1776        }
1777
1778        // Set timeout value
1779        $timeout = $this->config['Catalog']['http_timeout'] ?? 30;
1780        // Make sure keepalive is disabled as this is known to cause problems:
1781        $client->setOptions(
1782            ['timeout' => $timeout, 'useragent' => 'VuFind', 'keepalive' => false]
1783        );
1784
1785        // Set Accept header
1786        $client->getRequest()->getHeaders()->addHeaderLine(
1787            'Accept',
1788            'application/json'
1789        );
1790
1791        return $client;
1792    }
1793
1794    /**
1795     * Make Request
1796     *
1797     * Makes a request to the Koha REST API
1798     *
1799     * @param array $request Either a path as string or non-keyed array of path
1800     *                       elements, or a keyed array of request parameters:
1801     *
1802     * path     String or array of values to embed in the URL path. String is taken
1803     *          as is, array elements are url-encoded.
1804     * query    URL parameters (optional)
1805     * method   HTTP method (default is GET)
1806     * form     Form request params (optional)
1807     * json     JSON request as a PHP array (optional, only when form is not
1808     *          specified)
1809     * headers  Headers
1810     * errors   If true, return errors instead of raising an exception
1811     *
1812     * @return array
1813     * @throws ILSException
1814     */
1815    protected function makeRequest($request)
1816    {
1817        // Set up the request
1818        $apiUrl = $this->config['Catalog']['host'] . '/';
1819
1820        // Handle the simple case of just a path in $request
1821        if (is_string($request) || !isset($request['path'])) {
1822            $request = [
1823                'path' => $request,
1824            ];
1825        }
1826
1827        if (is_array($request['path'])) {
1828            $apiUrl .= implode('/', array_map('urlencode', $request['path']));
1829        } else {
1830            $apiUrl .= $request['path'];
1831        }
1832
1833        $client = $this->createHttpClient($apiUrl);
1834        $client->getRequest()->getHeaders()
1835            ->addHeaderLine('Authorization', $this->getOAuth2Token());
1836
1837        // Add params
1838        if (!empty($request['query'])) {
1839            $client->setParameterGet($request['query']);
1840        }
1841        if (!empty($request['form'])) {
1842            $client->setParameterPost($request['form']);
1843        } elseif (!empty($request['json'])) {
1844            $client->getRequest()->setContent(json_encode($request['json']));
1845            $client->getRequest()->getHeaders()->addHeaderLine(
1846                'Content-Type',
1847                'application/json'
1848            );
1849        }
1850
1851        if (!empty($request['headers'])) {
1852            $requestHeaders = $client->getRequest()->getHeaders();
1853            foreach ($request['headers'] as $name => $value) {
1854                $requestHeaders->addHeaderLine($name, [$value]);
1855            }
1856        }
1857
1858        // Send request and retrieve response
1859        $method = $request['method'] ?? 'GET';
1860        $startTime = microtime(true);
1861        $client->setMethod($method);
1862
1863        try {
1864            $response = $client->send();
1865        } catch (\Exception $e) {
1866            $this->logError(
1867                "$method request for '$apiUrl' failed: " . $e->getMessage()
1868            );
1869            throw new ILSException('Problem with Koha REST API.');
1870        }
1871
1872        // If we get a 401, we need to renew the access token and try again
1873        if ($response->getStatusCode() == 401) {
1874            $client->getRequest()->getHeaders()
1875                ->addHeaderLine('Authorization', $this->getOAuth2Token(true));
1876
1877            try {
1878                $response = $client->send();
1879            } catch (\Exception $e) {
1880                $this->logError(
1881                    "$method request for '$apiUrl' failed: " . $e->getMessage()
1882                );
1883                throw new ILSException('Problem with Koha REST API.');
1884            }
1885        }
1886
1887        $result = $response->getBody();
1888
1889        $fullUrl = $apiUrl;
1890        if ($method == 'GET') {
1891            $fullUrl .= '?' . $client->getRequest()->getQuery()->toString();
1892        }
1893        $this->debug(
1894            '[' . round(microtime(true) - $startTime, 4) . 's]'
1895            . " $method request $fullUrl" . PHP_EOL . 'response: ' . PHP_EOL
1896            . $result
1897        );
1898
1899        // Handle errors as complete failures only if the API call didn't return
1900        // valid JSON that the caller can handle
1901        $decodedResult = json_decode($result, true);
1902        if (
1903            empty($request['errors']) && !$response->isSuccess()
1904            && (null === $decodedResult || !empty($decodedResult['error'])
1905            || !empty($decodedResult['errors']))
1906        ) {
1907            $params = $method == 'GET'
1908                ? $client->getRequest()->getQuery()->toString()
1909                : $client->getRequest()->getPost()->toString();
1910            $this->logError(
1911                "$method request for '$apiUrl' with params '$params' and contents '"
1912                . $client->getRequest()->getContent() . "' failed: "
1913                . $response->getStatusCode() . ': ' . $response->getReasonPhrase()
1914                . ', response content: ' . $response->getBody()
1915            );
1916            throw new ILSException('Problem with Koha REST API.');
1917        }
1918
1919        return [
1920            'data' => $decodedResult,
1921            'code' => (int)$response->getStatusCode(),
1922            'headers' => $response->getHeaders()->toArray(),
1923        ];
1924    }
1925
1926    /**
1927     * Get a new or cached OAuth2 token (type + token)
1928     *
1929     * @param bool $renew Force renewal of token
1930     *
1931     * @return string
1932     */
1933    protected function getOAuth2Token($renew = false)
1934    {
1935        $cacheKey = 'oauth';
1936
1937        if (!$renew) {
1938            $token = $this->getCachedData($cacheKey);
1939            if ($token) {
1940                return $token;
1941            }
1942        }
1943
1944        $url = $this->config['Catalog']['host'] . '/v1/oauth/token';
1945
1946        try {
1947            $token = $this->getNewOAuth2Token(
1948                $url,
1949                $this->config['Catalog']['clientId'],
1950                $this->config['Catalog']['clientSecret'],
1951                $this->config['Catalog']['grantType'] ?? 'client_credentials'
1952            );
1953        } catch (AuthTokenException $exception) {
1954            throw new ILSException(
1955                'Problem with Koha REST API: ' . $exception->getMessage()
1956            );
1957        }
1958
1959        $this->putCachedData(
1960            $cacheKey,
1961            $token->getHeaderValue(),
1962            $token->getExpiresIn()
1963        );
1964
1965        return $token->getHeaderValue();
1966    }
1967
1968    /**
1969     * Get Item Statuses
1970     *
1971     * This is responsible for retrieving the status information of a certain
1972     * record.
1973     *
1974     * @param string $id     The record id to retrieve the holdings for
1975     * @param array  $patron Patron information, if available
1976     *
1977     * @return array An associative array with the following keys:
1978     * id, availability (boolean), status, location, reserve, callnumber.
1979     */
1980    protected function getItemStatusesForBiblio($id, $patron = null)
1981    {
1982        $result = $this->makeRequest(
1983            [
1984                'path' => [
1985                    'v1', 'contrib', 'kohasuomi', 'availability', 'biblios', $id,
1986                    'search',
1987                ],
1988                'errors' => true,
1989            ]
1990        );
1991        if (404 == $result['code']) {
1992            return [];
1993        }
1994        if (200 != $result['code']) {
1995            throw new ILSException('Problem with Koha REST API.');
1996        }
1997
1998        if (empty($result['data']['item_availabilities'])) {
1999            return [];
2000        }
2001
2002        $statuses = [];
2003        foreach ($result['data']['item_availabilities'] as $i => $item) {
2004            $avail = $item['availability'];
2005            $available = $avail['available'];
2006            $statusCodes = $this->getItemStatusCodes($item);
2007            $status = $this->pickStatus($statusCodes);
2008            if (isset($avail['unavailabilities']['Item::CheckedOut']['due_date'])) {
2009                $duedate = $this->convertDate(
2010                    $avail['unavailabilities']['Item::CheckedOut']['due_date'],
2011                    true
2012                );
2013            } else {
2014                $duedate = null;
2015            }
2016
2017            $entry = [
2018                'id' => $id,
2019                'item_id' => $item['item_id'],
2020                'location' => $this->getItemLocationName($item),
2021                'availability' => $available,
2022                'status' => $status,
2023                'status_array' => $statusCodes,
2024                'reserve' => 'N',
2025                'callnumber' => $this->getItemCallNumber($item),
2026                'duedate' => $duedate,
2027                'number' => $item['serial_issue_number'],
2028                'barcode' => $item['external_id'],
2029                'sort' => $i,
2030                'requests_placed' => max(
2031                    [$item['hold_queue_length'],
2032                    $result['data']['hold_queue_length']]
2033                ),
2034            ];
2035            if (!empty($item['public_notes'])) {
2036                $entry['item_notes'] = [$item['public_notes']];
2037            }
2038
2039            if ($patron && $this->itemHoldAllowed($item)) {
2040                $entry['is_holdable'] = true;
2041                $entry['level'] = 'copy';
2042                $entry['addLink'] = 'check';
2043            } else {
2044                $entry['is_holdable'] = false;
2045            }
2046
2047            if ($patron && $this->itemArticleRequestAllowed($item)) {
2048                $entry['storageRetrievalRequest'] = 'auto';
2049                $entry['addStorageRetrievalRequestLink'] = 'check';
2050            }
2051
2052            $statuses[] = $entry;
2053        }
2054
2055        usort($statuses, [$this, 'statusSortFunction']);
2056        return $statuses;
2057    }
2058
2059    /**
2060     * Get statuses for an item
2061     *
2062     * @param array $item Item from Koha
2063     *
2064     * @return array Status array and possible due date
2065     */
2066    protected function getItemStatusCodes($item)
2067    {
2068        $statuses = [];
2069        if ($item['availability']['available']) {
2070            $statuses[] = 'On Shelf';
2071        } elseif (isset($item['availability']['unavailabilities'])) {
2072            foreach ($item['availability']['unavailabilities'] as $code => $data) {
2073                // If we have a direct mapping, use it:
2074                if (isset($this->itemStatusMappings[$code])) {
2075                    $statuses[] = $this->itemStatusMappings[$code];
2076                    continue;
2077                }
2078
2079                // Check for a mapping method for the unavailability reason:
2080                if ($methodName = ($this->itemStatusMappingMethods[$code] ?? '')) {
2081                    $statuses[]
2082                        = call_user_func([$this, $methodName], $code, $data, $item);
2083                } else {
2084                    if (!empty($data['code'])) {
2085                        $statuses[] = $data['code'];
2086                    } else {
2087                        $parts = explode('::', $code, 2);
2088                        if (isset($parts[1])) {
2089                            $statuses[] = $parts[1];
2090                        }
2091                    }
2092                }
2093            }
2094            if (empty($statuses)) {
2095                $statuses[] = 'Not Available';
2096            }
2097        } else {
2098            $this->logError(
2099                'Unable to determine status for item: ' . $this->varDump($item)
2100            );
2101        }
2102
2103        if (empty($statuses)) {
2104            $statuses[] = 'No information available';
2105        }
2106        return array_unique($statuses);
2107    }
2108
2109    /**
2110     * Get item status code for CheckedOut status
2111     *
2112     * @param string $code Status code
2113     * @param array  $data Status data
2114     * @param array  $item Item
2115     *
2116     * @return string
2117     *
2118     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
2119     */
2120    protected function getStatusCodeItemCheckedOut($code, $data, $item)
2121    {
2122        $overdue = false;
2123        if (!empty($data['due_date'])) {
2124            $duedate = $this->dateConverter->convert(
2125                'Y-m-d',
2126                'U',
2127                $data['due_date']
2128            );
2129            $overdue = $duedate < time();
2130        }
2131        return $overdue ? 'Overdue' : 'Charged';
2132    }
2133
2134    /**
2135     * Get item status code for NotForLoan or Lost status
2136     *
2137     * @param string $code Status code
2138     * @param array  $data Status data
2139     * @param array  $item Item
2140     *
2141     * @return string
2142     *
2143     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
2144     */
2145    protected function getStatusCodeItemNotForLoanOrLost($code, $data, $item)
2146    {
2147        // NotForLoan and Lost are special: status has a library-specific
2148        // status number. Allow mapping of different status numbers
2149        // separately (e.g. Item::NotForLoan with status number 4
2150        // is mapped with key Item::NotForLoan4):
2151        $statusKey = $code . ($data['status'] ?? '-');
2152        // Replace ':' in status key if used as status since ':' is
2153        // the namespace separator in translatable strings:
2154        return $this->itemStatusMappings[$statusKey]
2155            ?? $data['code'] ?? str_replace(':', '_', $statusKey);
2156    }
2157
2158    /**
2159     * Get item status code for Transfer status
2160     *
2161     * @param string $code Status code
2162     * @param array  $data Status data
2163     * @param array  $item Item
2164     *
2165     * @return string
2166     *
2167     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
2168     */
2169    protected function getStatusCodeItemTransfer($code, $data, $item)
2170    {
2171        $onHold = array_key_exists(
2172            'Item::Held',
2173            $item['availability']['notes'] ?? []
2174        );
2175        return $onHold ? 'In Transit On Hold' : 'In Transit';
2176    }
2177
2178    /**
2179     * Status item sort function
2180     *
2181     * @param array $a First status record to compare
2182     * @param array $b Second status record to compare
2183     *
2184     * @return int
2185     */
2186    protected function statusSortFunction($a, $b)
2187    {
2188        $result = $this->getSorter()->compare($a['location'], $b['location']);
2189
2190        if (0 === $result && $this->sortItemsBySerialIssue) {
2191            $result = strnatcmp($a['number'] ?? '', $b['number'] ?? '');
2192        }
2193
2194        if (0 === $result) {
2195            $result = $a['sort'] - $b['sort'];
2196        }
2197        return $result;
2198    }
2199
2200    /**
2201     * Check if an item is holdable
2202     *
2203     * @param array $item Item from Koha
2204     *
2205     * @return bool
2206     */
2207    protected function itemHoldAllowed($item)
2208    {
2209        $unavail = $item['availability']['unavailabilities'] ?? [];
2210        if (!isset($unavail['Hold::NotHoldable'])) {
2211            return true;
2212        }
2213        return false;
2214    }
2215
2216    /**
2217     * Check if an article request can be placed on the item
2218     *
2219     * @param array $item Item from Koha
2220     *
2221     * @return bool
2222     */
2223    protected function itemArticleRequestAllowed($item)
2224    {
2225        $unavail = $item['availability']['unavailabilities'] ?? [];
2226        if (isset($unavail['ArticleRequest::NotAllowed'])) {
2227            return false;
2228        }
2229        if (
2230            empty($this->config['StorageRetrievalRequests']['allow_checked_out'])
2231            && isset($unavail['Item::CheckedOut'])
2232        ) {
2233            return false;
2234        }
2235        return true;
2236    }
2237
2238    /**
2239     * Protected support method to pick which status message to display when multiple
2240     * options are present.
2241     *
2242     * @param array $statusArray Array of status messages to choose from.
2243     *
2244     * @throws ILSException
2245     * @return string            The best status message to display.
2246     */
2247    protected function pickStatus($statusArray)
2248    {
2249        // Pick the first entry by default, then see if we can find a better match:
2250        $status = $statusArray[0];
2251        $rank = $this->getStatusRanking($status);
2252        for ($x = 1; $x < count($statusArray); $x++) {
2253            if ($this->getStatusRanking($statusArray[$x]) < $rank) {
2254                $status = $statusArray[$x];
2255            }
2256        }
2257
2258        return $status;
2259    }
2260
2261    /**
2262     * Support method for pickStatus() -- get the ranking value of the specified
2263     * status message.
2264     *
2265     * @param string $status Status message to look up
2266     *
2267     * @return int
2268     */
2269    protected function getStatusRanking($status)
2270    {
2271        return $this->statusRankings[$status] ?? 32000;
2272    }
2273
2274    /**
2275     * Get libraries from cache or from the API
2276     *
2277     * @return array
2278     */
2279    protected function getLibraries()
2280    {
2281        $cacheKey = 'libraries';
2282        $libraries = $this->getCachedData($cacheKey);
2283        if (null === $libraries) {
2284            $result = $this->makeRequest('v1/libraries?_per_page=-1');
2285            $libraries = [];
2286            foreach ($result['data'] as $library) {
2287                $libraries[$library['library_id']] = $library;
2288            }
2289            $this->putCachedData($cacheKey, $libraries, 3600);
2290        }
2291        return $libraries;
2292    }
2293
2294    /**
2295     * Get library name
2296     *
2297     * @param string $library Library ID
2298     *
2299     * @return string
2300     */
2301    protected function getLibraryName($library)
2302    {
2303        $libraries = $this->getLibraries();
2304        return $libraries[$library]['name'] ?? '';
2305    }
2306
2307    /**
2308     * Get patron's blocks, if any
2309     *
2310     * @param array $patron Patron
2311     *
2312     * @return mixed        A boolean false if no blocks are in place and an array
2313     * of block reasons if blocks are in place
2314     */
2315    protected function getPatronBlocks($patron)
2316    {
2317        $patronId = $patron['id'];
2318        $cacheId = "blocks|$patronId";
2319        $blockReason = $this->getCachedData($cacheId);
2320        if (null === $blockReason) {
2321            $result = $this->makeRequest(
2322                [
2323                    'path' => [
2324                        'v1', 'contrib', 'kohasuomi', 'patrons', $patron['id'],
2325                    ],
2326                    'query' => ['query_blocks' => 1],
2327                ]
2328            );
2329            $blockReason = [];
2330            if (!empty($result['data']['blocks'])) {
2331                $nonHoldBlock = false;
2332                foreach ($result['data']['blocks'] as $reason => $details) {
2333                    if ($reason !== 'Hold::MaximumHoldsReached') {
2334                        $nonHoldBlock = true;
2335                    }
2336                    $description = $this->getPatronBlockReason($reason, $details);
2337                    if ($description) {
2338                        $blockReason[] = $description;
2339                    }
2340                }
2341                // Add the generic block message to the beginning if we have blocks
2342                // other than hold block
2343                if ($nonHoldBlock) {
2344                    array_unshift(
2345                        $blockReason,
2346                        $this->translate('patron_status_card_blocked')
2347                    );
2348                }
2349            }
2350            $this->putCachedData($cacheId, $blockReason);
2351        }
2352        return empty($blockReason) ? false : $blockReason;
2353    }
2354
2355    /**
2356     * Fetch an item record from Koha
2357     *
2358     * @param int $id Item id
2359     *
2360     * @return array|null
2361     */
2362    protected function getItem($id)
2363    {
2364        $cacheId = "items|$id";
2365        $item = $this->getCachedData($cacheId);
2366        if (null === $item) {
2367            $result = $this->makeRequest(['v1', 'items', $id]);
2368            $item = $result['data'] ?? false;
2369            $this->putCachedData($cacheId, $item, 300);
2370        }
2371        return $item ?: null;
2372    }
2373
2374    /**
2375     * Fetch a biblio record from Koha
2376     *
2377     * @param int $id Bib record id
2378     *
2379     * @return array|null
2380     */
2381    protected function getBiblio($id)
2382    {
2383        static $cachedRecords = [];
2384        if (!isset($cachedRecords[$id])) {
2385            $result = $this->makeRequest(['v1', 'biblios', $id]);
2386            $cachedRecords[$id] = $result['data'] ?? false;
2387        }
2388        return $cachedRecords[$id];
2389    }
2390
2391    /**
2392     * Is the selected pickup location valid for the hold?
2393     *
2394     * @param string $pickUpLocation Selected pickup location
2395     * @param array  $patron         Patron information returned by the patronLogin
2396     * method.
2397     * @param array  $holdDetails    Details of hold being placed
2398     *
2399     * @return bool
2400     */
2401    protected function pickUpLocationIsValid($pickUpLocation, $patron, $holdDetails)
2402    {
2403        $pickUpLibs = $this->getPickUpLocations($patron, $holdDetails);
2404        foreach ($pickUpLibs as $location) {
2405            if ($location['locationID'] == $pickUpLocation) {
2406                return true;
2407            }
2408        }
2409        return false;
2410    }
2411
2412    /**
2413     * Return a hold error message
2414     *
2415     * @param string $error Error message
2416     *
2417     * @return array
2418     */
2419    protected function holdError($error)
2420    {
2421        switch ($error) {
2422            case 'Hold cannot be placed. Reason: tooManyReserves':
2423            case 'Hold cannot be placed. Reason: tooManyHoldsForThisRecord':
2424                $error = 'hold_error_too_many_holds';
2425                break;
2426            case 'Hold cannot be placed. Reason: ageRestricted':
2427                $error = 'hold_error_age_restricted';
2428                break;
2429        }
2430        return [
2431            'success' => false,
2432            'sysMessage' => $error,
2433        ];
2434    }
2435
2436    /**
2437     * Map a Koha renewal block reason code to a VuFind translation string
2438     *
2439     * @param string $reason Koha block code
2440     * @param string $itype  Koha item type
2441     *
2442     * @return string
2443     */
2444    protected function mapRenewalBlockReason($reason, $itype)
2445    {
2446        return $this->config['ItemTypeRenewalBlockMappings'][$itype][$reason]
2447            ?? $this->renewalBlockMappings[$reason]
2448            ?? 'renew_item_no';
2449    }
2450
2451    /**
2452     * Return a location for a Koha item
2453     *
2454     * @param array $item Item
2455     *
2456     * @return string
2457     */
2458    protected function getItemLocationName($item)
2459    {
2460        $libraryId = (!$this->useHomeLibrary && null !== $item['holding_library_id'])
2461            ? $item['holding_library_id'] : $item['home_library_id'];
2462        $name = $this->translateLocation($libraryId);
2463        if ($name === $libraryId) {
2464            $libraries = $this->getLibraries();
2465            $name = isset($libraries[$libraryId])
2466                ? $libraries[$libraryId]['name'] : $libraryId;
2467        }
2468        return $name;
2469    }
2470
2471    /**
2472     * Translate location name
2473     *
2474     * @param string $location Location code
2475     * @param string $default  Default value if translation is not available
2476     *
2477     * @return string
2478     */
2479    protected function translateLocation($location, $default = null)
2480    {
2481        if (empty($location)) {
2482            return $default ?? '';
2483        }
2484        $prefix = 'location_';
2485        return $this->translate(
2486            "$prefix$location",
2487            null,
2488            $default ?? $location
2489        );
2490    }
2491
2492    /**
2493     * Return a call number for a Koha item
2494     *
2495     * @param array $item Item
2496     *
2497     * @return string
2498     */
2499    protected function getItemCallNumber($item)
2500    {
2501        return $item['callnumber'];
2502    }
2503
2504    /**
2505     * Get a reason for why a hold cannot be placed
2506     *
2507     * @param array $result Hold check result
2508     *
2509     * @return string
2510     */
2511    protected function getHoldBlockReason($result)
2512    {
2513        if (!empty($result['availability']['unavailabilities'])) {
2514            foreach (
2515                array_keys($result['availability']['unavailabilities']) as $key
2516            ) {
2517                switch ($key) {
2518                    case 'Biblio::NoAvailableItems':
2519                        return 'hold_error_not_holdable';
2520                    case 'Item::NotForLoan':
2521                    case 'Hold::NotAllowedInOPAC':
2522                    case 'Hold::ZeroHoldsAllowed':
2523                    case 'Hold::NotAllowedByLibrary':
2524                    case 'Hold::NotAllowedFromOtherLibraries':
2525                    case 'Item::Restricted':
2526                    case 'Hold::ItemLevelHoldNotAllowed':
2527                        return 'hold_error_item_not_holdable';
2528                    case 'Hold::MaximumHoldsForRecordReached':
2529                    case 'Hold::MaximumHoldsReached':
2530                        return 'hold_error_too_many_holds';
2531                    case 'Item::AlreadyHeldForThisPatron':
2532                        return 'hold_error_already_held';
2533                    case 'Hold::OnShelfNotAllowed':
2534                        return 'hold_error_on_shelf_blocked';
2535                }
2536            }
2537        }
2538        return 'hold_error_blocked';
2539    }
2540
2541    /**
2542     * Converts given key to corresponding parameter
2543     *
2544     * @param string $key     to convert
2545     * @param string $default value to return
2546     *
2547     * @return string
2548     */
2549    protected function getSortParamValue($key, $default = '')
2550    {
2551        $params = [
2552            'checkout' => 'issuedate',
2553            'return' => 'returndate',
2554            'lastrenewed' => 'lastreneweddate',
2555            'title' => 'title',
2556        ];
2557
2558        return $params[$key] ?? $default;
2559    }
2560
2561    /**
2562     * Get a complete title from all the title-related fields
2563     *
2564     * @param array $biblio Biblio record (or something with the correct fields)
2565     *
2566     * @return string
2567     */
2568    protected function getBiblioTitle($biblio)
2569    {
2570        $title = [];
2571        foreach (['title', 'subtitle', 'part_number', 'part_name'] as $field) {
2572            $content = $biblio[$field] ?? '';
2573            if ($content) {
2574                $title[] = $content;
2575            }
2576        }
2577        return implode(' ', $title);
2578    }
2579
2580    /**
2581     * Convert a date to display format
2582     *
2583     * @param string $date     Date
2584     * @param bool   $withTime Whether the date includes time
2585     *
2586     * @return string
2587     */
2588    protected function convertDate($date, $withTime = false)
2589    {
2590        if (!$date) {
2591            return '';
2592        }
2593        $createFormat = $withTime ? 'Y-m-d\TH:i:sP' : 'Y-m-d';
2594        return $this->dateConverter->convertToDisplayDate($createFormat, $date);
2595    }
2596
2597    /**
2598     * Get Patron Transactions
2599     *
2600     * This is responsible for retrieving all transactions (i.e. checked-out items
2601     * or checked-in items) by a specific patron.
2602     *
2603     * @param array $patron    The patron array from patronLogin
2604     * @param array $params    Parameters
2605     * @param bool  $checkedIn Whether to list checked-in items
2606     *
2607     * @throws DateException
2608     * @throws ILSException
2609     * @return array        Array of the patron's transactions on success.
2610     */
2611    protected function getTransactions($patron, $params, $checkedIn)
2612    {
2613        $pageSize = $params['limit'] ?? 50;
2614        $sort = $params['sort'] ?? '+due_date';
2615        if ('+title' === $sort) {
2616            $sort = '+title|+subtitle';
2617        } elseif ('-title' === $sort) {
2618            $sort = '-title|-subtitle';
2619        }
2620        $queryParams = [
2621            '_order_by' => $sort,
2622            '_page' => $params['page'] ?? 1,
2623            '_per_page' => $pageSize,
2624        ];
2625        if ($checkedIn) {
2626            $queryParams['checked_in'] = '1';
2627            $arrayKey = 'transactions';
2628        } else {
2629            $arrayKey = 'records';
2630        }
2631        $result = $this->makeRequest(
2632            [
2633                'path' => [
2634                    'v1', 'contrib', 'kohasuomi', 'patrons', $patron['id'],
2635                    'checkouts',
2636                ],
2637                'query' => $queryParams,
2638            ]
2639        );
2640
2641        if (200 !== $result['code']) {
2642            throw new ILSException('Problem with Koha REST API.');
2643        }
2644
2645        if (empty($result['data'])) {
2646            return [
2647                'count' => 0,
2648                $arrayKey => [],
2649            ];
2650        }
2651        $transactions = [];
2652        foreach ($result['data'] as $entry) {
2653            $dueStatus = false;
2654            $now = time();
2655            $dueTimeStamp = strtotime($entry['due_date']);
2656            if (is_numeric($dueTimeStamp)) {
2657                if ($now > $dueTimeStamp) {
2658                    $dueStatus = 'overdue';
2659                } elseif ($now > $dueTimeStamp - (1 * 24 * 60 * 60)) {
2660                    $dueStatus = 'due';
2661                }
2662            }
2663
2664            $renewable = $entry['renewable'];
2665            // Koha 22.11 introduced a backward compatibility break by renaming
2666            // renewals to renewals_count (bug 30275), so check both:
2667            $renewals = $entry['renewals_count'] ?? $entry['renewals'];
2668            $renewLimit = $entry['max_renewals'];
2669            $message = '';
2670            if (!$renewable && !$checkedIn) {
2671                $message = $this->mapRenewalBlockReason(
2672                    $entry['renewability_blocks'],
2673                    $entry['item_itype']
2674                );
2675                $permanent = in_array(
2676                    $entry['renewability_blocks'],
2677                    $this->permanentRenewalBlocks
2678                );
2679                if ($permanent) {
2680                    $renewals = null;
2681                    $renewLimit = null;
2682                }
2683            }
2684
2685            $transaction = [
2686                'id' => $entry['biblio_id'],
2687                'checkout_id' => $entry['checkout_id'],
2688                'item_id' => $entry['item_id'],
2689                'barcode' => $entry['external_id'] ?? null,
2690                'title' => $this->getBiblioTitle($entry),
2691                // enumchron should have been mapped to serial_issue_number, but the
2692                // mapping is missing from all plugin versions up to v22.05.02:
2693                'volume' => $entry['serial_issue_number'] ?? $entry['enumchron']
2694                    ?? '',
2695                'publication_year' => $entry['copyright_date']
2696                    ?? $entry['publication_year'] ?? '',
2697                'borrowingLocation' => $this->getLibraryName($entry['library_id']),
2698                'checkoutDate' => $this->convertDate($entry['checkout_date']),
2699                'duedate' => $this->convertDate($entry['due_date'], true),
2700                'returnDate' => $this->convertDate($entry['checkin_date']),
2701                'dueStatus' => $dueStatus,
2702                'renew' => $renewals,
2703                'renewLimit' => $renewLimit,
2704                'renewable' => $renewable,
2705                'message' => $message,
2706            ];
2707
2708            $transactions[] = $transaction;
2709        }
2710
2711        return [
2712            'count' => $result['headers']['X-Total-Count'] ?? count($transactions),
2713            $arrayKey => $transactions,
2714        ];
2715    }
2716
2717    /**
2718     * Get a description for a block
2719     *
2720     * @param string $reason  Koha block reason
2721     * @param array  $details Any details related to the reason
2722     *
2723     * @return string
2724     */
2725    protected function getPatronBlockReason($reason, $details)
2726    {
2727        $params = [];
2728        switch ($reason) {
2729            case 'Hold::MaximumHoldsReached':
2730                $params = [
2731                    '%%blockCount%%' => $details['current_hold_count'],
2732                    '%%blockLimit%%' => $details['max_holds_allowed'],
2733                ];
2734                break;
2735            case 'Patron::Debt':
2736            case 'Patron::DebtGuarantees':
2737                $count = isset($details['current_outstanding'])
2738                    ? $this->formatMoney($details['current_outstanding']) : '-';
2739                $limit = isset($details['max_outstanding'])
2740                    ? $this->formatMoney($details['max_outstanding']) : '-';
2741                $params = [
2742                    '%%blockCount%%' => $count,
2743                    '%%blockLimit%%' => $limit,
2744                ];
2745                break;
2746        }
2747        return $this->translate($this->patronStatusMappings[$reason] ?? '', $params);
2748    }
2749
2750    /**
2751     * Helper function for formatting currency
2752     *
2753     * @param float $amount Number to format
2754     *
2755     * @return string
2756     */
2757    protected function formatMoney($amount)
2758    {
2759        return $this->currencyFormatter->convertToDisplayFormat($amount);
2760    }
2761}