Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
11.98% covered (danger)
11.98%
69 / 576
11.11% covered (danger)
11.11%
4 / 36
CRAP
0.00% covered (danger)
0.00%
0 / 1
Unicorn
11.98% covered (danger)
11.98%
69 / 576
11.11% covered (danger)
11.11%
4 / 36
13892.96
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init
22.22% covered (danger)
22.22%
2 / 9
0.00% covered (danger)
0.00%
0 / 1
16.76
 getConfig
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getPickUpLocations
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 getDefaultPickUpLocation
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 getRenewDetails
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 renewMyItems
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
42
 getStatus
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
56
 getStatuses
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 getPurchaseHistory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHolding
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 placeHold
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
20
 patronLogin
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
20
 getMyProfile
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
2
 getMyFines
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
20
 getMyHolds
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
20
 getCancelHoldDetails
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 cancelHolds
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
72
 getMyTransactions
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
72
 getCourses
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 getInstructors
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 getDepartments
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 findReserves
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
156
 getNewItems
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 getSuppressedRecords
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 parseStatusLine
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
110
 mapLocation
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
4.12
 mapLibrary
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
4.12
 querySirsi
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 calculateRecallDueDate
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 parseDateTime
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 formatDateTime
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 toUTF8
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 processMarcHoldingLocation
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 decodeMarcHoldingRecord
76.60% covered (warning)
76.60%
36 / 47
0.00% covered (danger)
0.00%
0 / 1
17.88
 getMarcHoldings
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * SirsiDynix Unicorn ILS Driver (VuFind side)
5 *
6 * PHP version 8
7 *
8 * This program is free software; you can redistribute it and/or modify
9 * it under the terms of the GNU General Public License version 2,
10 * as published by the Free Software Foundation.
11 *
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 * GNU General Public License for more details.
16 *
17 * You should have received a copy of the GNU General Public License
18 * along with this program; if not, write to the Free Software
19 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
20 *
21 * @category VuFind
22 * @package  ILS_Drivers
23 * @author   Tuan Nguyen <tuan@yorku.ca>
24 * @author   Drew Farrugia <vufind-unicorn-l@lists.lehigh.edu>
25 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
26 * @link     http://code.google.com/p/vufind-unicorn/ vufind-unicorn project
27 */
28
29namespace VuFind\ILS\Driver;
30
31use VuFind\Date\DateException;
32use VuFind\Exception\ILS as ILSException;
33use VuFind\Marc\MarcCollection;
34use VuFind\Marc\MarcReader;
35
36use function array_key_exists;
37use function array_slice;
38use function count;
39use function floatval;
40use function in_array;
41use function strlen;
42
43/**
44 * SirsiDynix Unicorn ILS Driver (VuFind side)
45 *
46 * IMPORTANT: To use this driver you need to download the SirsiDynix API driver.pl
47 * from http://code.google.com/p/vufind-unicorn/ and install it on your Sirsi
48 * Unicorn/Symphony server. Please note: currently you will need to download
49 * the driver.pl in the yorku branch on google code to use this driver.
50 *
51 * @category VuFind
52 * @package  ILS_Drivers
53 * @author   Tuan Nguyen <tuan@yorku.ca>
54 * @author   Drew Farrugia <vufind-unicorn-l@lists.lehigh.edu>
55 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
56 * @link     http://code.google.com/p/vufind-unicorn/ vufind-unicorn project
57 **/
58class Unicorn extends AbstractBase implements
59    \VuFindHttp\HttpServiceAwareInterface,
60    \VuFind\I18n\HasSorterInterface
61{
62    use \VuFindHttp\HttpServiceAwareTrait;
63    use \VuFind\I18n\HasSorterTrait;
64
65    /**
66     * Host
67     *
68     * @var string
69     */
70    protected $host;
71
72    /**
73     * Port
74     *
75     * @var string
76     */
77    protected $port;
78
79    /**
80     * Name of API program
81     *
82     * @var string
83     */
84    protected $search_prog;
85
86    /**
87     * Full URL to API (alternative to host/port/search_prog)
88     *
89     * @var string
90     */
91    protected $url;
92
93    /**
94     * Date converter object
95     *
96     * @var \VuFind\Date\Converter
97     */
98    protected $dateConverter;
99
100    /**
101     * Constructor
102     *
103     * @param \VuFind\Date\Converter $dateConverter Date converter object
104     */
105    public function __construct(\VuFind\Date\Converter $dateConverter)
106    {
107        $this->dateConverter = $dateConverter;
108    }
109
110    /**
111     * Initialize the driver.
112     *
113     * Validate configuration and perform all resource-intensive tasks needed to
114     * make the driver active.
115     *
116     * @throws ILSException
117     * @return void
118     */
119    public function init()
120    {
121        if (empty($this->config)) {
122            throw new ILSException('Configuration needs to be set.');
123        }
124
125        // allow user to specify the full url to the Sirsi side perl script
126        $this->url = $this->config['Catalog']['url'];
127
128        // host/port/search_prog kept for backward compatibility
129        if (
130            isset($this->config['Catalog']['host'])
131            && isset($this->config['Catalog']['port'])
132            && isset($this->config['Catalog']['search_prog'])
133        ) {
134            $this->host = $this->config['Catalog']['host'];
135            $this->port = $this->config['Catalog']['port'];
136            $this->search_prog = $this->config['Catalog']['search_prog'];
137        }
138    }
139
140    /**
141     * Public Function which retrieves renew, hold and cancel settings from the
142     * driver ini file.
143     *
144     * @param string $function The name of the feature to be checked
145     * @param array  $params   Optional feature-specific parameters (array)
146     *
147     * @return array An array with key-value pairs.
148     *
149     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
150     */
151    public function getConfig($function, $params = [])
152    {
153        if (isset($this->config[$function])) {
154            $functionConfig = $this->config[$function];
155        } else {
156            $functionConfig = false;
157        }
158        return $functionConfig;
159    }
160
161    /**
162     * Get Pick Up Locations
163     *
164     * This is responsible for gettting a list of valid library locations for
165     * holds / recall retrieval
166     *
167     * @param array $patron      Patron information returned by the patronLogin
168     * method.
169     * @param array $holdDetails Optional array, only passed in when getting a list
170     * in the context of placing or editing a hold. When placing a hold, it contains
171     * most of the same values passed to placeHold, minus the patron data. When
172     * editing a hold it contains all the hold information returned by getMyHolds.
173     * May be used to limit the pickup options or may be ignored. The driver must
174     * not add new options to the return array based on this data or other areas of
175     * VuFind may behave incorrectly.
176     *
177     * @throws ILSException
178     * @return array        An array of associative arrays with locationID and
179     * locationDisplay keys
180     *
181     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
182     */
183    public function getPickUpLocations($patron = false, $holdDetails = null)
184    {
185        $params = ['query' => 'libraries'];
186        $response = $this->querySirsi($params);
187        $response = rtrim($response);
188        $lines = explode("\n", $response);
189        $libraries = [];
190
191        foreach ($lines as $line) {
192            [$code, $name] = explode('|', $line);
193            $libraries[] = [
194                'locationID' => $code,
195                'locationDisplay' => empty($name) ? $code : $name,
196            ];
197        }
198        return $libraries;
199    }
200
201    /**
202     * Get Default Pick Up Location
203     *
204     * Returns the default pick up location set in VoyagerRestful.ini
205     *
206     * @param array $patron      Patron information returned by the patronLogin
207     * method.
208     * @param array $holdDetails Optional array, only passed in when getting a list
209     * in the context of placing a hold; contains most of the same values passed to
210     * placeHold, minus the patron data. May be used to limit the pickup options
211     * or may be ignored.
212     *
213     * @return string       The default pickup location for the patron.
214     *
215     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
216     */
217    public function getDefaultPickUpLocation($patron = false, $holdDetails = null)
218    {
219        if ($patron && isset($patron['library'])) {
220            return $patron['library'];
221        }
222        return $this->config['Holds']['defaultPickupLocation'];
223    }
224
225    /**
226     * Get Renew Details
227     *
228     * In order to renew an item, Voyager requires the patron details and an item
229     * id. This function returns the item id as a string which is then used
230     * as submitted form data in checkedOut.php. This value is then extracted by
231     * the RenewMyItems function.
232     *
233     * @param array $checkOutDetails An array of item data
234     *
235     * @return string Data for use in a form field
236     */
237    public function getRenewDetails($checkOutDetails)
238    {
239        return $checkOutDetails['item_id'];
240    }
241
242    /**
243     * Renew My Items
244     *
245     * Function for attempting to renew a patron's items. The data in
246     * $renewDetails['details'] is determined by getRenewDetails().
247     *
248     * @param array $renewDetails An array of data required for renewing items
249     * including the Patron ID and an array of renewal IDS
250     *
251     * @return array              An array of renewal information keyed by item ID
252     */
253    public function renewMyItems($renewDetails)
254    {
255        $patron = $renewDetails['patron'];
256        $details = $renewDetails['details'];
257
258        $chargeKeys = implode(',', $details);
259        $params = [
260          'query' => 'renew_items', 'chargeKeys' => $chargeKeys,
261          'patronId' => $patron['cat_username'], 'pin' => $patron['cat_password'],
262          'library' => $patron['library'],
263        ];
264        $response = $this->querySirsi($params);
265
266        // process the API response
267        if ($response == 'invalid_login') {
268            return ['blocks' => ['authentication_error_admin']];
269        }
270
271        $results = [];
272        $lines = explode("\n", $response);
273        foreach ($lines as $line) {
274            [$chargeKey, $result] = explode('-----API_RESULT-----', $line);
275            $results[$chargeKey] = ['item_id' => $chargeKey];
276            $matches = [];
277            preg_match('/\^MN([0-9][0-9][0-9])/', $result, $matches);
278            if (isset($matches[1])) {
279                $status = $matches[1];
280                if ($status == '214') {
281                    $results[$chargeKey]['success'] = true;
282                } else {
283                    $results[$chargeKey]['success'] = false;
284                    $results[$chargeKey]['sysMessage']
285                        = $this->config['ApiMessages'][$status];
286                }
287            }
288            preg_match('/\^CI([^\^]+)\^/', $result, $matches);
289            if (isset($matches[1])) {
290                [$newDate, $newTime] = explode(',', $matches[1]);
291                $results[$chargeKey]['new_date'] = $newDate;
292                $results[$chargeKey]['new_time'] = $newTime;
293            }
294        }
295        return ['details' => $results];
296    }
297
298    /**
299     * Get Status
300     *
301     * This is responsible for retrieving the status information of a certain
302     * record.
303     *
304     * @param string $id The record id to retrieve the holdings for
305     *
306     * @throws ILSException
307     * @return mixed     On success, an associative array with the following keys:
308     * id, availability (boolean), status, location, reserve, callnumber.
309     */
310    public function getStatus($id)
311    {
312        $params = ['query' => 'single', 'id' => $id];
313        $response = $this->querySirsi($params);
314        if (empty($response)) {
315            return [];
316        }
317
318        // separate the item lines and the MARC holdings records
319        $marc_marker = '-----BEGIN MARC-----';
320        $marc_marker_pos = strpos($response, $marc_marker);
321        $lines = ($marc_marker_pos !== false)
322            ? substr($response, 0, $marc_marker_pos) : '';
323        $marc = ($marc_marker_pos !== false)
324            ? substr($response, $marc_marker_pos + strlen($marc_marker)) : '';
325
326        // Initialize item holdings the ones received in MARC holding
327        // records
328        $items = $this->getMarcHoldings($marc);
329
330        // Then add the ones from bibliographic records
331        $lines = explode("\n", rtrim($lines));
332        foreach ($lines as $line) {
333            $item = $this->parseStatusLine($line);
334            $items[] = $item;
335        }
336
337        // sort the items by shelving key in descending order, then ascending by
338        // copy number
339        $cmp = function ($a, $b) {
340            if ($a['shelving_key'] == $b['shelving_key']) {
341                return $a['number'] - $b['number'];
342            }
343            return $a['shelving_key'] < $b['shelving_key'] ? 1 : -1;
344        };
345        usort($items, $cmp);
346
347        return $items;
348    }
349
350    /**
351     * Get Statuses
352     *
353     * This is responsible for retrieving the status information for a
354     * collection of records.
355     *
356     * @param array $idList The array of record ids to retrieve the status for
357     *
358     * @throws ILSException
359     * @return array        An array of getStatus() return values on success.
360     */
361    public function getStatuses($idList)
362    {
363        $statuses = [];
364        $params = [
365            'query' => 'multiple', 'ids' => implode('|', array_unique($idList)),
366        ];
367        $response = $this->querySirsi($params);
368        if (empty($response)) {
369            return [];
370        }
371        $lines = explode("\n", $response);
372
373        $currentId = null;
374        $group = -1;
375        foreach ($lines as $line) {
376            $item = $this->parseStatusLine($line);
377            if ($item['id'] != $currentId) {
378                $currentId = $item['id'];
379                $statuses[] = [];
380                $group++;
381            }
382            $statuses[$group][] = $item;
383        }
384        return $statuses;
385    }
386
387    /**
388     * Get Purchase History
389     *
390     * This is responsible for retrieving the acquisitions history data for the
391     * specific record (usually recently received issues of a serial).
392     *
393     * @param string $id The record id to retrieve the info for
394     *
395     * @throws ILSException
396     * @return array     An array with the acquisitions data on success.
397     *
398     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
399     */
400    public function getPurchaseHistory($id)
401    {
402        // TODO
403        return [];
404    }
405
406    /**
407     * Get Holding
408     *
409     * This is responsible for retrieving the holding information of a certain
410     * record.
411     *
412     * @param string $id      The record id to retrieve the holdings for
413     * @param array  $patron  Patron data
414     * @param array  $options Extra options (not currently used)
415     *
416     * @throws DateException
417     * @throws ILSException
418     * @return array         On success, an associative array with the following
419     * keys: id, availability (boolean), status, location, reserve, callnumber,
420     * duedate, number, barcode.
421     *
422     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
423     */
424    public function getHolding($id, array $patron = null, array $options = [])
425    {
426        return $this->getStatus($id);
427    }
428
429    /**
430     * Place Hold
431     *
432     * Attempts to place a hold or recall on a particular item and returns
433     * an array with result details or throws an exception on failure of support
434     * classes
435     *
436     * @param array $holdDetails An array of item and patron data
437     *
438     * @throws ILSException
439     * @return mixed An array of data on the request including
440     * whether or not it was successful and a system message (if available)
441     */
442    public function placeHold($holdDetails)
443    {
444        $patron = $holdDetails['patron'];
445
446        // convert expire date from display format
447        // to the format Symphony/Unicorn expects
448        $expire = $holdDetails['requiredBy'];
449        $expire = $this->dateConverter->convertFromDisplayDate(
450            $this->config['Catalog']['server_date_format'],
451            $expire
452        );
453
454        // query sirsi
455        $params = [
456            'query' => 'hold',
457            'itemId' => $holdDetails['item_id'],
458            'patronId' => $patron['cat_username'],
459            'pin' => $patron['cat_password'],
460            'pickup' => $holdDetails['pickUpLocation'],
461            'expire' => $expire,
462            'comments' => $holdDetails['comment'],
463            'holdType' => $holdDetails['level'],
464            'callnumber' => $holdDetails['callnumber'],
465            'override' => $holdDetails['override'],
466        ];
467        $response = $this->querySirsi($params);
468
469        // process the API response
470        if ($response == 'invalid_login') {
471            return [
472              'success' => false,
473              'sysMessage' => 'authentication_error_admin'];
474        }
475
476        $matches = [];
477        preg_match('/\^MN([0-9][0-9][0-9])/', $response, $matches);
478        if (isset($matches[1])) {
479            $status = $matches[1];
480            if ($status == '209') {
481                return ['success' => true];
482            } else {
483                return [
484                  'success' => false,
485                  'sysMessage' => $this->config['ApiMessages'][$status]];
486            }
487        }
488
489        return ['success' => false];
490    }
491
492    /**
493     * Patron Login
494     *
495     * This is responsible for authenticating a patron against the catalog.
496     *
497     * @param string $username The patron username
498     * @param string $password The patron's password
499     *
500     * @throws ILSException
501     * @return mixed          Associative array of patron info on successful login,
502     * null on unsuccessful login.
503     */
504    public function patronLogin($username, $password)
505    {
506        //query sirsi
507        $params = [
508            'query' => 'login', 'patronId' => $username, 'pin' => $password,
509        ];
510        $response = $this->querySirsi($params);
511
512        if (empty($response)) {
513            return null;
514        }
515
516        [$user_key, $alt_id, $barcode, $name, $library, $profile, $cat1, $cat2,
517            $cat3, $cat4, $cat5, $expiry, $holds, $status] = explode('|', $response);
518
519        [$last, $first] = explode(',', $name);
520        $first = rtrim($first, ' ');
521
522        if ($expiry != '0') {
523            $expiry = $this->parseDateTime(trim($expiry));
524        }
525        $expired = ($expiry == '0') ? false : $expiry < time();
526        return [
527            'id' => $username,
528            'firstname' => $first,
529            'lastname' =>  $last,
530            'cat_username' => $username,
531            'cat_password' => $password,
532            'email' => null,
533            'major' => null,
534            'college' => null,
535            'library' => $library,
536            'barcode' => $barcode,
537            'alt_id' => $alt_id,
538            'cat1' => $cat1,
539            'cat2' => $cat2,
540            'cat3' => $cat3,
541            'cat4' => $cat4,
542            'cat5' => $cat5,
543            'profile' => $profile,
544            'expiry_date' => $this->formatDateTime($expiry),
545            'expired' => $expired,
546            'number_of_holds' => $holds,
547            'status' => $status,
548            'user_key' => $user_key,
549        ];
550    }
551
552    /**
553     * Get Patron Profile
554     *
555     * This is responsible for retrieving the profile for a specific patron.
556     *
557     * @param array $patron The patron array
558     *
559     * @throws ILSException
560     * @return array        Array of the patron's profile data on success.
561     */
562    public function getMyProfile($patron)
563    {
564        $username = $patron['cat_username'];
565        $password = $patron['cat_password'];
566
567        //query sirsi
568        $params = [
569            'query' => 'profile', 'patronId' => $username, 'pin' => $password,
570        ];
571        $response = $this->querySirsi($params);
572
573        [, , , , $library, $profile, , , , , , , , $email, $address1, $zip, $phone,
574            $address2] = explode('|', $response);
575
576        return [
577            'firstname' => $patron['firstname'],
578            'lastname' => $patron['lastname'],
579            'address1' => $address1,
580            'address2' => $address2,
581            'zip' => $zip,
582            'phone' => $phone,
583            'email' => $email,
584            'group' => $profile,
585            'library' => $library,
586        ];
587    }
588
589    /**
590     * Get Patron Fines
591     *
592     * This is responsible for retrieving all fines by a specific patron.
593     *
594     * @param array $patron The patron array from patronLogin
595     *
596     * @throws DateException
597     * @throws ILSException
598     * @return mixed        Array of the patron's fines on success.
599     */
600    public function getMyFines($patron)
601    {
602        $username = $patron['cat_username'];
603        $password = $patron['cat_password'];
604
605        $params = [
606            'query' => 'fines', 'patronId' => $username, 'pin' => $password,
607        ];
608        $response = $this->querySirsi($params);
609        if (empty($response)) {
610            return [];
611        }
612        $lines = explode("\n", $response);
613        $items = [];
614        foreach ($lines as $item) {
615            [$catkey, $amount, $balance, $date_billed, $number_of_payments,
616                $with_items, $reason, $date_charged, $duedate, $date_recalled]
617                    = explode('|', $item);
618
619            // the amount and balance are in cents, so we need to turn them into
620            // dollars if configured
621            if (!$this->config['Catalog']['leaveFinesAmountsInCents']) {
622                $amount = (floatval($amount) / 100.00);
623                $balance = (floatval($balance) / 100.00);
624            }
625
626            $date_billed = $this->parseDateTime($date_billed);
627            $date_charged = $this->parseDateTime($date_charged);
628            $duedate = $this->parseDateTime($duedate);
629            $date_recalled = $this->parseDateTime($date_recalled);
630            $items[] = [
631                'id' => $catkey,
632                'amount' => $amount,
633                'balance' => $balance,
634                'date_billed' => $this->formatDateTime($date_billed),
635                'number_of_payments' => $number_of_payments,
636                'with_items' => $with_items,
637                'fine' => $reason,
638                'checkout' => $this->formatDateTime($date_charged),
639                'duedate' => $this->formatDateTime($duedate),
640                'date_recalled' => $this->formatDateTime($date_recalled),
641            ];
642        }
643
644        return $items;
645    }
646
647    /**
648     * Get Patron Holds
649     *
650     * This is responsible for retrieving all holds by a specific patron.
651     *
652     * @param array $patron The patron array from patronLogin
653     *
654     * @throws DateException
655     * @throws ILSException
656     * @return array        Array of the patron's holds on success.
657     */
658    public function getMyHolds($patron)
659    {
660        $username = $patron['cat_username'];
661        $password = $patron['cat_password'];
662
663        $params = [
664            'query' => 'getholds', 'patronId' => $username, 'pin' => $password,
665        ];
666        $response = $this->querySirsi($params);
667        if (empty($response)) {
668            return [];
669        }
670        $lines = explode("\n", $response);
671        $items = [];
672        foreach ($lines as $item) {
673            [$catkey, $holdkey, $available, , $date_expires, , $date_created, ,
674                $type, $pickup_library, , , , , , , $barcode] = explode('|', $item);
675
676            $date_created = $this->parseDateTime($date_created);
677            $date_expires = $this->parseDateTime($date_expires);
678            $items[] = [
679                'id' => $catkey,
680                'reqnum' => $holdkey,
681                'available' => ($available == 'Y') ? true : false,
682                'expire' => $this->formatDateTime($date_expires),
683                'create' => $this->formatDateTime($date_created),
684                'type' => $type,
685                'location' => $pickup_library,
686                'item_id' => $holdkey,
687                'barcode' => trim($barcode),
688            ];
689        }
690
691        return $items;
692    }
693
694    /**
695     * Get Cancel Hold Details
696     *
697     * In order to cancel a hold, Voyager requires the patron details an item ID
698     * and a recall ID. This function returns the item id and recall id as a string
699     * separated by a pipe, which is then submitted as form data in Hold.php. This
700     * value is then extracted by the CancelHolds function.
701     *
702     * @param array $holdDetails A single hold array from getMyHolds
703     * @param array $patron      Patron information from patronLogin
704     *
705     * @return string Data for use in a form field
706     *
707     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
708     */
709    public function getCancelHoldDetails($holdDetails, $patron = [])
710    {
711        return $holdDetails['item_id'];
712    }
713
714    /**
715     * Cancel Holds
716     *
717     * Attempts to Cancel a hold or recall on a particular item. The
718     * data in $cancelDetails['details'] is determined by getCancelHoldDetails().
719     *
720     * @param array $cancelDetails An array of item and patron data
721     *
722     * @return array               An array of data on each request including
723     * whether or not it was successful and a system message (if available)
724     */
725    public function cancelHolds($cancelDetails)
726    {
727        $patron = $cancelDetails['patron'];
728        $details = $cancelDetails['details'];
729        $params = [
730            'query' => 'cancelHolds',
731            'patronId' => $patron['cat_username'], 'pin' => $patron['cat_password'],
732            'holdId' => implode('|', $details),
733        ];
734        $response = $this->querySirsi($params);
735
736        // process response
737        if (empty($response) || $response == 'invalid_login') {
738            return false;
739        }
740
741        // break the response into separate lines
742        $lines = explode("\n", $response);
743
744        // if there are more than 1 lines, then there is at least 1 failure
745        $failures = [];
746        if (count($lines) > 1) {
747            // extract the failed IDs.
748            foreach ($lines as $line) {
749                // error lines start with '**'
750                if (str_starts_with(trim($line), '**')) {
751                    [, $holdKey] = explode(':', $line);
752                    $failures[] = trim($holdKey, '()');
753                }
754            }
755        }
756
757        $count = 0;
758        $items = [];
759        foreach ($details as $holdKey) {
760            if (in_array($holdKey, $failures)) {
761                $items[$holdKey] = [
762                    'success' => false, 'status' => 'hold_cancel_fail',
763                ];
764            } else {
765                $count++;
766                $items[$holdKey] = [
767                  'success' => true, 'status' => 'hold_cancel_success',
768                ];
769            }
770        }
771        $result = ['count' => $count, 'items' => $items];
772        return $result;
773    }
774
775    /**
776     * Get Patron Transactions
777     *
778     * This is responsible for retrieving all transactions (i.e. checked out items)
779     * by a specific patron.
780     *
781     * @param array $patron The patron array from patronLogin
782     *
783     * @throws DateException
784     * @throws ILSException
785     * @return array        Array of the patron's transactions on success.
786     */
787    public function getMyTransactions($patron)
788    {
789        $username = $patron['cat_username'];
790        $password = $patron['cat_password'];
791
792        $params = [
793            'query' => 'transactions', 'patronId' => $username, 'pin' => $password,
794        ];
795        $response = $this->querySirsi($params);
796        if (empty($response)) {
797            return [];
798        }
799        $item_lines = explode("\n", $response);
800        $items = [];
801        foreach ($item_lines as $item) {
802            [$catkey, $date_charged, $duedate, $date_renewed, $accrued_fine,
803                $overdue, $number_of_renewals, $date_recalled, $charge_key1,
804                $charge_key2, $charge_key3, $charge_key4, $recall_period, $callnum]
805                    = explode('|', $item);
806
807            $duedate = $original_duedate = $this->parseDateTime($duedate);
808            $recall_duedate = false;
809            $date_recalled = $this->parseDateTime($date_recalled);
810            if ($date_recalled) {
811                $duedate = $recall_duedate = $this->calculateRecallDueDate(
812                    $date_recalled,
813                    $recall_period,
814                    $original_duedate
815                );
816            }
817            $charge_key = "$charge_key1|$charge_key2|$charge_key3|$charge_key4";
818            $items[] = [
819                'id' => $catkey,
820                'date_charged' =>
821                    $this->formatDateTime($this->parseDateTime($date_charged)),
822                'duedate' => $this->formatDateTime($duedate),
823                'duedate_raw' => $duedate, // unformatted duedate used for sorting
824                'date_renewed' =>
825                    $this->formatDateTime($this->parseDateTime($date_renewed)),
826                'accrued_fine' => $accrued_fine,
827                'overdue' => $overdue,
828                'number_of_renewals' => $number_of_renewals,
829                'date_recalled' => $this->formatDateTime($date_recalled),
830                'recall_duedate' => $this->formatDateTime($recall_duedate),
831                'original_duedate' => $this->formatDateTime($original_duedate),
832                'renewable' => true,
833                'charge_key' => $charge_key,
834                'item_id' => $charge_key,
835                'callnum' => $callnum,
836                'dueStatus' => $overdue == 'Y' ? 'overdue' : '',
837            ];
838        }
839
840        // sort the items by due date
841        $cmp = function ($a, $b) {
842            if ($a['duedate_raw'] == $b['duedate_raw']) {
843                return $a['id'] < $b['id'] ? -1 : 1;
844            }
845            return $a['duedate_raw'] < $b['duedate_raw'] ? -1 : 1;
846        };
847        usort($items, $cmp);
848
849        return $items;
850    }
851
852    /**
853     * Get Courses
854     *
855     * Obtain a list of courses for use in limiting the reserves list.
856     *
857     * @throws ILSException
858     * @return array An associative array with key = ID, value = name.
859     */
860    public function getCourses()
861    {
862        //query sirsi
863        $params = ['query' => 'courses'];
864        $response = $this->querySirsi($params);
865
866        $response = rtrim($response);
867        $course_lines = explode("\n", $response);
868        $courses = [];
869
870        foreach ($course_lines as $course) {
871            [$id, $code, $name] = explode('|', $course);
872            $name = ($code == $name) ? $name : $code . ' - ' . $name;
873            $courses[$id] = $name;
874        }
875        $this->getSorter()->asort($courses);
876        return $courses;
877    }
878
879    /**
880     * Get Instructors
881     *
882     * Obtain a list of instructors for use in limiting the reserves list.
883     *
884     * @throws ILSException
885     * @return array An associative array with key = ID, value = name.
886     */
887    public function getInstructors()
888    {
889        //query sirsi
890        $params = ['query' => 'instructors'];
891        $response = $this->querySirsi($params);
892
893        $response = rtrim($response);
894        $user_lines = explode("\n", $response);
895        $users = [];
896
897        foreach ($user_lines as $user) {
898            [$id, $name] = explode('|', $user);
899            $users[$id] = $name;
900        }
901        $this->getSorter()->asort($users);
902        return $users;
903    }
904
905    /**
906     * Get Departments
907     *
908     * Obtain a list of departments for use in limiting the reserves list.
909     *
910     * @throws ILSException
911     * @return array An associative array with key = dept. ID, value = dept. name.
912     */
913    public function getDepartments()
914    {
915        //query sirsi
916        $params = ['query' => 'desks'];
917        $response = $this->querySirsi($params);
918
919        $response = rtrim($response);
920        $dept_lines = explode("\n", $response);
921        $depts = [];
922
923        foreach ($dept_lines as $dept) {
924            [$id, $name] = explode('|', $dept);
925            $depts[$id] = $name;
926        }
927        $this->getSorter()->asort($depts);
928        return $depts;
929    }
930
931    /**
932     * Find Reserves
933     *
934     * Obtain information on course reserves.
935     *
936     * @param string $courseId     ID from getCourses (empty string to match all)
937     * @param string $instructorId ID from getInstructors (empty string to match all)
938     * @param string $departmentId ID from getDepartments (empty string to match all)
939     *
940     * @throws ILSException
941     * @return array               An array of associative arrays representing
942     * reserve items.
943     */
944    public function findReserves($courseId, $instructorId, $departmentId)
945    {
946        //query sirsi
947        if ($courseId) {
948            $params = [
949                'query' => 'reserves', 'course' => $courseId, 'instructor' => '',
950                'desk' => '',
951            ];
952        } elseif ($instructorId) {
953            $params = [
954                'query' => 'reserves', 'course' => '', 'instructor' => $instructorId,
955                'desk' => '',
956            ];
957        } elseif ($departmentId) {
958            $params = [
959                'query' => 'reserves', 'course' => '', 'instructor' => '',
960                'desk' => $departmentId,
961            ];
962        } else {
963            $params = [
964                'query' => 'reserves', 'course' => '', 'instructor' => '',
965                'desk' => '',
966            ];
967        }
968
969        $response = $this->querySirsi($params);
970
971        $item_lines = explode("\n", $response);
972        $items = [];
973        foreach ($item_lines as $item) {
974            [$instructor_id, $course_id, $dept_id, $bib_id]
975                = explode('|', $item);
976            if (
977                $bib_id && (empty($instructorId) || $instructorId == $instructor_id)
978                && (empty($courseId) || $courseId == $course_id)
979                && (empty($departmentId) || $departmentId == $dept_id)
980            ) {
981                $items[] = [
982                    'BIB_ID' => $bib_id,
983                    'INSTRUCTOR_ID' => $instructor_id,
984                    'COURSE_ID' => $course_id,
985                    'DEPARTMENT_ID' => $dept_id,
986                ];
987            }
988        }
989        return $items;
990    }
991
992    /**
993     * Get New Items
994     *
995     * Retrieve the IDs of items recently added to the catalog.
996     *
997     * @param int $page    Page number of results to retrieve (counting starts at 1)
998     * @param int $limit   The size of each page of results to retrieve
999     * @param int $daysOld The maximum age of records to retrieve in days (max. 30)
1000     * @param int $fundId  optional fund ID to use for limiting results (use a value
1001     * returned by getFunds, or exclude for no limit); note that "fund" may be a
1002     * misnomer - if funds are not an appropriate way to limit your new item
1003     * results, you can return a different set of values from getFunds. The
1004     * important thing is that this parameter supports an ID returned by getFunds,
1005     * whatever that may mean.
1006     *
1007     * @throws ILSException
1008     * @return array       Associative array with 'count' and 'results' keys
1009     *
1010     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1011     */
1012    public function getNewItems($page, $limit, $daysOld, $fundId = null)
1013    {
1014        //query sirsi
1015        //  isset($lib)
1016        // ? $params = array('query' => 'newItems',
1017        // 'lib' => array_search($lib, $config['Libraries']))
1018        // : $params = array('query' => 'newItems');
1019        $params = ['query' => 'newitems', 'lib' => 'PPL'];
1020        $response = $this->querySirsi($params);
1021
1022        $item_lines = explode("\n", rtrim($response));
1023
1024        $rescount = 0;
1025        foreach ($item_lines as $item) {
1026            $item = rtrim($item, '|');
1027            $items[$item] = [
1028                'id' => $item,
1029            ];
1030            $rescount++;
1031        }
1032
1033        $results = array_slice($items, ($page - 1) * $limit, ($page * $limit) - 1);
1034        return ['count' => $rescount, 'results' => $results];
1035    }
1036
1037    /**
1038     * Get suppressed records.
1039     *
1040     * @throws ILSException
1041     * @return array ID numbers of suppressed records in the system.
1042     */
1043    public function getSuppressedRecords()
1044    {
1045        $params = ['query' => 'shadowed'];
1046        $response = $this->querySirsi($params);
1047
1048        $record_lines = explode("\n", rtrim($response));
1049        $records = [];
1050        foreach ($record_lines as $record) {
1051            $record = rtrim($record, '|');
1052            $records[] = $record;
1053        }
1054
1055        return $records;
1056    }
1057
1058    /**
1059     * Parse a pipe-delimited status line received from the script on the
1060     * Unicorn/Symphony server.
1061     *
1062     * @param string $line The pipe-delimited status line to parse.
1063     *
1064     * @return array       Associative array of holding information
1065     */
1066    protected function parseStatusLine($line)
1067    {
1068        [$catkey, $shelving_key, $callnum, $itemkey1, $itemkey2, $itemkey3,
1069            $barcode, $reserve, $number_of_charges, $item_type, $recirculate_flag,
1070            $holdcount, $library_code, $library, $location_code, $location,
1071            $currLocCode, $current_location, $holdable, $circulation_rule, $duedate,
1072            $date_recalled, $recall_period, $format, $title_holds]
1073                = explode('|', $line);
1074
1075        // availability
1076        $availability = ($number_of_charges == 0) ? 1 : 0;
1077
1078        // due date (if checked out)
1079        $duedate = $this->parseDateTime(trim($duedate));
1080
1081        // date recalled
1082        $date_recalled = $this->parseDateTime(trim($date_recalled));
1083
1084        // a recalled item has a new due date, we have to calculate that new due date
1085        if ($date_recalled !== false) {
1086            $duedate = $this->calculateRecallDueDate(
1087                $date_recalled,
1088                $recall_period,
1089                $duedate
1090            );
1091        }
1092
1093        // item status
1094        $status = ($availability) ? 'Available' : 'Checked Out';
1095
1096        // even though item is NOT checked out, it still may not be "Available"
1097        // the following are the special cases
1098        if (
1099            isset($this->config['UnavailableItemTypes'])
1100            && isset($this->config['UnavailableItemTypes'][$item_type])
1101        ) {
1102            $availability = 0;
1103            $status = $this->config['UnavailableItemTypes'][$item_type];
1104        } elseif (
1105            isset($this->config['UnavailableLocations'])
1106            && isset($this->config['UnavailableLocations'][$currLocCode])
1107        ) {
1108            $availability = 0;
1109            $status = $this->config['UnavailableLocations'][$currLocCode];
1110        }
1111
1112        $item = [
1113            'status' => $status,
1114            'availability' => $availability,
1115            'id' => $catkey,
1116            'number' => $itemkey3, // copy number
1117            'duedate' => $this->formatDateTime($duedate),
1118            'callnumber' => $callnum,
1119            'reserve' => ($reserve == '0') ? 'N' : 'Y',
1120            'location_code' => $location_code,
1121            'location' => $location,
1122            'home_location_code' => $location_code,
1123            'home_location' => $location,
1124            'library_code' => $library_code,
1125            'library' => ($library) ? $library : $library_code,
1126            'barcode' => trim($barcode),
1127            'item_id' => trim($barcode),
1128            'is_holdable' => $holdable,
1129            'requests_placed' => $holdcount + $title_holds,
1130            'current_location_code' => $currLocCode,
1131            'current_location' => $current_location,
1132            'item_type' => $item_type,
1133            'recirculate_flag' => $recirculate_flag,
1134            'shelving_key' => $shelving_key,
1135            'circulation_rule' => $circulation_rule,
1136            'date_recalled' => $this->formatDateTime($date_recalled),
1137            'item_key' => $itemkey1 . '|' . $itemkey2 . '|' . $itemkey3 . '|',
1138            'format' => $format,
1139            ];
1140
1141        return $item;
1142    }
1143
1144    /**
1145     * Map the location code to friendly name.
1146     *
1147     * @param string $code The location code from Unicorn/Symphony
1148     *
1149     * @return string      The friendly name if defined, otherwise the code is
1150     * returned.
1151     */
1152    protected function mapLocation($code)
1153    {
1154        if (
1155            isset($this->config['Locations'])
1156            && isset($this->config['Locations'][$code])
1157        ) {
1158            return $this->config['Locations'][$code];
1159        }
1160        return $code;
1161    }
1162
1163    /**
1164     * Maps the library code to friendly library name.
1165     *
1166     * @param string $code The library code from Unicorn/Symphony
1167     *
1168     * @return string      The library friendly name if defined, otherwise the code
1169     * is returned.
1170     */
1171    protected function mapLibrary($code)
1172    {
1173        if (
1174            isset($this->config['Libraries'])
1175            && isset($this->config['Libraries'][$code])
1176        ) {
1177            return $this->config['Libraries'][$code];
1178        }
1179        return $code;
1180    }
1181
1182    /**
1183     * Send a request to the SIRSI side API script and returns the response.
1184     *
1185     * @param array $params Associative array of query parameters to send.
1186     *
1187     * @return string
1188     */
1189    protected function querySirsi($params)
1190    {
1191        // make sure null parameters are sent as empty strings instead or else the
1192        // driver.pl may choke on null parameter values
1193        foreach ($params as $key => $value) {
1194            if ($value == null) {
1195                $params[$key] = '';
1196            }
1197        }
1198
1199        $url = $this->url;
1200        if (empty($url)) {
1201            $url = $this->host;
1202            if ($this->port) {
1203                $url = 'http://' . $url . ':' . $this->port . '/' .
1204                    $this->search_prog;
1205            } else {
1206                $url = 'http://' . $url . '/' . $this->search_prog;
1207            }
1208        }
1209
1210        $httpClient = $this->httpService->createClient($url, 'POST');
1211        $httpClient->setRawBody(http_build_query($params));
1212        $httpClient->setEncType('application/x-www-form-urlencoded');
1213        // use HTTP POST so parameters like user id and PIN are NOT logged by web
1214        // servers
1215        $result = $httpClient->send();
1216
1217        // Even if we get a response, make sure it's a 'good' one.
1218        if (!$result->isSuccess()) {
1219            throw new ILSException("Error response code received from $url");
1220        }
1221
1222        // get the response data
1223        $response = $result->getBody();
1224
1225        return rtrim($response);
1226    }
1227
1228    /**
1229     * Given the date recalled, calculate the new due date based on circulation
1230     * policy.
1231     *
1232     * @param int $dateRecalled Unix time stamp of when the recall was issued.
1233     * @param int $recallPeriod Number of days to due date (from date recalled).
1234     * @param int $duedate      Original duedate.
1235     *
1236     * @return int              New due date as unix time stamp.
1237     */
1238    protected function calculateRecallDueDate($dateRecalled, $recallPeriod, $duedate)
1239    {
1240        // FIXME: There must be a better way of getting recall due date
1241        if ($dateRecalled) {
1242            $recallDue = $dateRecalled
1243                + (($recallPeriod + 1) * 24 * 60 * 60) - 60;
1244            return ($recallDue < $duedate) ? $recallDue : $duedate;
1245        }
1246        return false;
1247    }
1248
1249    /**
1250     * Take a date/time string from SIRSI seltool and convert it into unix time
1251     * stamp.
1252     *
1253     * @param string $date The input date string. Expected format YYYYMMDDHHMM.
1254     *
1255     * @return int         Unix time stamp if successful, false otherwise.
1256     */
1257    protected function parseDateTime($date)
1258    {
1259        if (strlen($date) >= 8) {
1260            // format is MM/DD/YYYY HH:MI so it can be passed to strtotime
1261            $formatted_date = substr($date, 4, 2) . '/' . substr($date, 6, 2) .
1262                    '/' . substr($date, 0, 4);
1263            if (strlen($date) > 8) {
1264                $formatted_date .= ' ' . substr($date, 8, 2) . ':' .
1265                substr($date, 10);
1266            }
1267            return strtotime($formatted_date);
1268        }
1269        return false;
1270    }
1271
1272    /**
1273     * Format the given unix time stamp to a human readable format. The format is
1274     * configurable in Unicorn.ini
1275     *
1276     * @param int $time Unix time stamp.
1277     *
1278     * @return string Formatted date/time.
1279     */
1280    protected function formatDateTime($time)
1281    {
1282        $dateTimeString = '';
1283        if ($time) {
1284            $dateTimeString = $this->dateConverter->convertToDisplayDate('U', $time);
1285        }
1286        return $dateTimeString;
1287    }
1288
1289    /**
1290     * Convert the given ISO-8859-1 string to UTF-8 if it is not already UTF-8.
1291     *
1292     * @param string $s The string to convert.
1293     *
1294     * @return string   The input string converted to UTF-8
1295     */
1296    protected function toUTF8($s)
1297    {
1298        return (mb_detect_encoding($s, 'UTF-8') == 'UTF-8') ? $s : utf8_encode($s);
1299    }
1300
1301    /**
1302     * Given a location field, return the values relevant to VuFind.
1303     *
1304     * This method is meant to be overridden in inheriting classes to
1305     * reflect local policies regarding interpretation of the a, b and
1306     * c subfields of  852.
1307     *
1308     * @param MarcReader $record MARC record.
1309     * @param array      $field  Location field to be processed.
1310     *
1311     * @return array Location information.
1312     */
1313    protected function processMarcHoldingLocation(MarcReader $record, $field)
1314    {
1315        $library_code  = $record->getSubfield($field, 'b');
1316        $location_code = $record->getSubfield($field, 'c');
1317        $location = [
1318            'library_code'  => $library_code,
1319            'library'       => $this->mapLibrary($library_code),
1320            'location_code' => $location_code,
1321            'location'      => $this->mapLocation($location_code),
1322            'notes'         => $record->getSubfields($field, 'z'),
1323            'marc852'       => $field,
1324        ];
1325        return $location;
1326    }
1327
1328    /**
1329     * Decode a MARC holding record.
1330     *
1331     * @param MarcReader $record Holding record to decode..
1332     *
1333     * @return array Has two elements: the first is the list of
1334     *               locations found in the record, the second are the
1335     *               decoded holdings per se.
1336     *
1337     * @todo Check if is OK to print multiple times textual holdings
1338     *       that had more than one $8.
1339     */
1340    protected function decodeMarcHoldingRecord(MarcReader $record)
1341    {
1342        $locations = [];
1343        $holdings = [];
1344        // First pass:
1345        //  - process locations
1346        //
1347        //  - collect textual holdings indexed by linking number to be
1348        //    able to easily check later what fields from enumeration
1349        //    and chronology they override.
1350        $textuals = [];
1351        $fields = array_merge($record->getFields('852'), $record->getFields('866'));
1352        foreach ($fields as $field) {
1353            switch ($field['tag']) {
1354                case '852':
1355                    $locations[]
1356                        = $this->processMarcHoldingLocation($record, $field);
1357                    break;
1358                case '866':
1359                    $linking_fields = $record->getSubfields($field, '8');
1360                    if ($linking_fields === false) {
1361                        // Skip textual holdings fields with no linking
1362                        continue 2;
1363                    }
1364                    foreach ($linking_fields as $linking_field) {
1365                        $linking = explode('.', $linking_field);
1366                        // Only the linking part is used in textual
1367                        // holdings...
1368                        $linking = $linking[0];
1369                        // and it should be an int.
1370                        $textuals[(int)($linking)] = &$field;
1371                    }
1372                    break;
1373            }
1374        }
1375
1376        // Second pass: enumeration and chronology, biblio
1377
1378        // Digits to use to build a combined index with linking number
1379        // and sequence number.
1380        // PS: Does this make this implementation year-3K safe?
1381        $link_digits = floor(strlen((string)PHP_INT_MAX) / 2);
1382
1383        $data863 = array_key_exists(0, $textuals) ? [] : $record->getFields('863');
1384        foreach ($data863 as $field) {
1385            $linking_field = $record->getSubfield($field, '8');
1386
1387            if ($linking_field === false) {
1388                // Skip record if there is no linking number
1389                continue;
1390            }
1391
1392            $linking = explode('.', $linking_field);
1393            if (1 < count($linking)) {
1394                $sequence = explode('\\', $linking[1]);
1395                // Lets ignore the link type, as we only care for \x
1396                $sequence = $sequence[0];
1397            } else {
1398                $sequence = 0;
1399            }
1400            $linking = $linking[0];
1401
1402            if (array_key_exists((int)$linking, $textuals)) {
1403                // Skip coded holdings overridden by textual
1404                // holdings
1405                continue;
1406            }
1407
1408            $decoded_holding = '';
1409            foreach ($field['subfields'] as $subfield) {
1410                if (str_contains('68x', $subfield['code'])) {
1411                    continue;
1412                }
1413                $decoded_holding .= ' ' . $subfield['data'];
1414            }
1415
1416            $ndx = (int)($linking
1417                          . sprintf("%0{$link_digits}u", $sequence));
1418            $holdings[$ndx] = trim($decoded_holding);
1419        }
1420
1421        foreach ($textuals as $linking => $field) {
1422            $textual_holding = $record->getSubfield($field, 'a');
1423            foreach ($record->getSubfields($field, 'z') as $note) {
1424                $textual_holding .= ' ' . $note;
1425            }
1426
1427            $ndx = (int)($linking . sprintf("%0{$link_digits}u", 0));
1428            $holdings[$ndx] = trim($textual_holding);
1429        }
1430
1431        return [$locations, $holdings];
1432    }
1433
1434    /**
1435     * Get textual holdings summary.
1436     *
1437     * @param string $marc Raw marc holdings records.
1438     *
1439     * @return array   Array of holdings data similar to the one returned by
1440     *                 getHolding.
1441     */
1442    protected function getMarcHoldings($marc)
1443    {
1444        $holdings = [];
1445        $collection = new MarcCollection($marc);
1446        foreach ($collection as $record) {
1447            [$locations, $record_holdings]
1448                = $this->decodeMarcHoldingRecord($record);
1449            // Flatten locations with corresponding holdings as VuFind
1450            // expects it.
1451            foreach ($locations as $location) {
1452                $holdings[] = array_merge_recursive(
1453                    $location,
1454                    ['summary' => $record_holdings]
1455                );
1456            }
1457        }
1458        return $holdings;
1459    }
1460}