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