Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
1.47% covered (danger)
1.47%
2 / 136
10.00% covered (danger)
10.00%
1 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Innovative
1.47% covered (danger)
1.47%
2 / 136
10.00% covered (danger)
10.00%
1 / 10
1011.48
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 sendRequest
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 prepID
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getStatus
0.00% covered (danger)
0.00%
0 / 60
0.00% covered (danger)
0.00%
0 / 1
156
 getStatuses
0.00% covered (danger)
0.00%
0 / 6
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
 getHoldLink
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getMyProfile
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 patronLogin
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2
3/**
4 * III ILS Driver
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2007.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  ILS_Drivers
25 * @author   Adam Brin <abrin@brynmawr.com>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
28 */
29
30namespace VuFind\ILS\Driver;
31
32use VuFind\Date\DateException;
33use VuFind\Exception\ILS as ILSException;
34
35use function count;
36use function strlen;
37
38/**
39 * VuFind Connector for Innovative
40 *
41 * This class uses screen scraping techniques to gather record holdings written
42 * by Adam Bryn of the Tri-College consortium.
43 *
44 * @category VuFind
45 * @package  ILS_Drivers
46 * @author   Adam Brin <abrin@brynmawr.com>
47 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
48 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
49 */
50class Innovative extends AbstractBase implements
51    \VuFindHttp\HttpServiceAwareInterface
52{
53    use \VuFindHttp\HttpServiceAwareTrait;
54
55    /**
56     * Initialize the driver.
57     *
58     * Validate configuration and perform all resource-intensive tasks needed to
59     * make the driver active.
60     *
61     * @throws ILSException
62     * @return void
63     */
64    public function init()
65    {
66        if (empty($this->config)) {
67            throw new ILSException('Configuration needs to be set.');
68        }
69    }
70
71    /**
72     * Make an HTTP request
73     *
74     * @param string $url URL to request
75     *
76     * @return string
77     */
78    protected function sendRequest($url)
79    {
80        // Make the NCIP request:
81        try {
82            $result = $this->httpService->get($url);
83        } catch (\Exception $e) {
84            $this->throwAsIlsException($e);
85        }
86
87        if (!$result->isSuccess()) {
88            throw new ILSException('HTTP error');
89        }
90
91        return $result->getBody();
92    }
93
94    /**
95     * Prepare ID
96     *
97     * This function returns the correct record id format as defined
98     * in the Innovative.ini file.
99     *
100     * @param string $id ID to format
101     *
102     * @return string
103     */
104    protected function prepID($id)
105    {
106        // Get the ID format from config (default to use_full_id if unset):
107        if (
108            !isset($this->config['RecordID']['use_full_id'])
109            || $this->config['RecordID']['use_full_id']
110        ) {
111            // Strip ID leading period and trailing check digit.
112            $id_ = substr(str_replace('.b', '', $id), 0, -1);
113        } else {
114            // Return digits only.
115            $id_ = substr($id, 1);
116        }
117        return $id_;
118    }
119
120    /**
121     * Get Status
122     *
123     * This is responsible for retrieving the status information of a certain
124     * record.
125     *
126     * @param string $id The record id to retrieve the holdings for
127     *
128     * @throws ILSException
129     * @return mixed     On success, an associative array with the following keys:
130     * id, availability (boolean), status, location, reserve, callnumber.
131     */
132    public function getStatus($id)
133    {
134        // Strip ID
135        $id_ = $this->prepID($id);
136
137        // Load Record Page
138        $host = rtrim($this->config['Catalog']['url'], '/');
139
140        // Grab the full item list view
141        //$result = $this->sendRequest($host . '/record=b' . $id_);
142        $result = $this->sendRequest(
143            $host . '/search/.b' . $id_ . '/.b' . $id_ .
144            '/1%2C1%2C1%2CB/holdings~' . $id_ . '&FF=&1%2C0%2C'
145        );
146
147        // strip out html before the first occurrence of 'bibItems', should be
148        // '<table class="bibItems" '
149        $r = substr($result, stripos($result, 'bibItems'));
150        // strip out the rest of the first table tag.
151        $r = substr($r, strpos($r, '>') + 1);
152        // strip out the next table closing tag and everything after it.
153        $r = substr($r, 0, stripos($r, '</table'));
154
155        // $r should only include the holdings table at this point
156
157        // split up into strings that contain each table row, excluding the
158        // beginning tr tag.
159        $rows = preg_split('/<tr([^>]*)>/', $r);
160        $count = 0;
161        $keys = array_pad([], 10, '');
162
163        $loc_col_name      = $this->config['OPAC']['location_column'];
164        $call_col_name     = $this->config['OPAC']['call_no_column'];
165        $status_col_name   = $this->config['OPAC']['status_column'];
166        $reserves_col_name = $this->config['OPAC']['location_column'];
167        $reserves_key_name = $this->config['OPAC']['reserves_key_name'];
168        $stat_avail        = $this->config['OPAC']['status_avail'];
169        $stat_due          = $this->config['OPAC']['status_due'];
170
171        $ret = [];
172        foreach ($rows as $row) {
173            // Split up the contents of the row based on the th or td tag, excluding
174            // the tags themselves.
175            $cols = preg_split('/<t(h|d)([^>]*)>/', $row);
176
177            // for each th or td section, do the following.
178            for ($i = 0; $i < count($cols); $i++) {
179                // replace non blocking space encodings with a space.
180                $cols[$i] = str_replace('&nbsp;', ' ', $cols[$i]);
181                // remove html comment tags
182                $cols[$i] = preg_replace('/<!--([^(-->)]*)-->/', '', $cols[$i]);
183                // Remove closing th or td tag, trim whitespace and decode html
184                // entities
185                $cols[$i] = html_entity_decode(
186                    trim(substr($cols[$i], 0, stripos($cols[$i], '</t')))
187                );
188
189                // If this is the first row, it is the header row and has the column
190                // names
191                if ($count == 1) {
192                    $keys[$i] = $cols[$i];
193                } elseif ($count > 1) { // not the first row, has holding info
194                    //look for location column
195                    if (stripos($keys[$i], (string)$loc_col_name) > -1) {
196                        $ret[$count - 2]['location'] = strip_tags($cols[$i]);
197                    }
198                    // Does column hold reserves information?
199                    if (stripos($keys[$i], (string)$reserves_col_name) > -1) {
200                        if (stripos($cols[$i], (string)$reserves_key_name) > -1) {
201                            $ret[$count - 2]['reserve'] = 'Y';
202                        } else {
203                            $ret[$count - 2]['reserve'] = 'N';
204                        }
205                    }
206                    // Does column hold call numbers?
207                    if (stripos($keys[$i], (string)$call_col_name) > -1) {
208                        $ret[$count - 2]['callnumber'] = strip_tags($cols[$i]);
209                    }
210                    // Look for status information.
211                    if (stripos($keys[$i], (string)$status_col_name) > -1) {
212                        if (stripos($cols[$i], (string)$stat_avail) > -1) {
213                            $ret[$count - 2]['status'] = 'Available On Shelf';
214                            $ret[$count - 2]['availability'] = 1;
215                        } else {
216                            $ret[$count - 2]['status'] = 'Available to request';
217                            $ret[$count - 2]['availability'] = 0;
218                        }
219                        if (stripos($cols[$i], (string)$stat_due) > -1) {
220                            $t = trim(
221                                substr(
222                                    $cols[$i],
223                                    stripos($cols[$i], (string)$stat_due)
224                                        + strlen($stat_due)
225                                )
226                            );
227                            $t = substr($t, 0, stripos($t, ' '));
228                            $ret[$count - 2]['duedate'] = $t;
229                        }
230                    }
231                    //$ret[$count-2][$keys[$i]] = $cols[$i];
232                    //$ret[$count-2]['id'] = $bibid;
233                    $ret[$count - 2]['id'] = $id;
234                    $ret[$count - 2]['number'] = ($count - 1);
235                    // Return a fake barcode so hold link is enabled
236                    // TODO: Should be dependent on settings variable, if bib level
237                    // holds.
238                    $ret[$count - 2]['barcode'] = '1234567890123';
239                }
240            }
241            $count++;
242        }
243        return $ret;
244    }
245
246    /**
247     * Get Statuses
248     *
249     * This is responsible for retrieving the status information for a
250     * collection of records.
251     *
252     * @param array $ids The array of record ids to retrieve the status for
253     *
254     * @throws ILSException
255     * @return array     An array of getStatus() return values on success.
256     */
257    public function getStatuses($ids)
258    {
259        $items = [];
260        $count = 0;
261        foreach ($ids as $id) {
262            $items[$count] = $this->getStatus($id);
263            $count++;
264        }
265        return $items;
266    }
267
268    /**
269     * Get Holding
270     *
271     * This is responsible for retrieving the holding information of a certain
272     * record.
273     *
274     * @param string $id      The record id to retrieve the holdings for
275     * @param array  $patron  Patron data
276     * @param array  $options Extra options (not currently used)
277     *
278     * @throws DateException
279     * @throws ILSException
280     * @return array         On success, an associative array with the following
281     * keys: id, availability (boolean), status, location, reserve, callnumber,
282     * duedate, number, barcode.
283     *
284     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
285     */
286    public function getHolding($id, array $patron = null, array $options = [])
287    {
288        return $this->getStatus($id);
289    }
290
291    /**
292     * Get Purchase History
293     *
294     * This is responsible for retrieving the acquisitions history data for the
295     * specific record (usually recently received issues of a serial).
296     *
297     * @param string $id The record id to retrieve the info for
298     *
299     * @throws ILSException
300     * @return array     An array with the acquisitions data on success.
301     *
302     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
303     */
304    public function getPurchaseHistory($id)
305    {
306        // TODO
307        return [];
308    }
309
310    /**
311     * Get Hold Link
312     *
313     * The goal for this method is to return a URL to a "place hold" web page on
314     * the ILS OPAC. This is used for ILSs that do not support an API or method
315     * to place Holds.
316     *
317     * @param string $id      The id of the bib record
318     * @param array  $details Item details from getHoldings return array
319     *
320     * @return string         URL to ILS's OPAC's place hold screen.
321     *
322     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
323     */
324    public function getHoldLink($id, $details)
325    {
326        // Strip ID
327        $id_ = $this->prepID($id);
328
329        //Build request link
330        $link = $this->config['Catalog']['url'] . '/search?/.b' . $id_ . '/.b' .
331            $id_ . '/1%2C1%2C1%2CB/request~b' . $id_;
332        //$link = $this->config['Catalog']['url'] . '/record=b' . $id_;
333
334        return $link;
335    }
336
337    /**
338     * Get Patron Profile
339     *
340     * This is responsible for retrieving the profile for a specific patron.
341     *
342     * @param array $userinfo The patron array
343     *
344     * @throws ILSException
345     * @return array          Array of the patron's profile data on success.
346     */
347    public function getMyProfile($userinfo)
348    {
349        return $userinfo;
350    }
351
352    /**
353     * Patron Login
354     *
355     * This is responsible for authenticating a patron against the catalog.
356     *
357     * @param string $username The patron username
358     * @param string $password The patron's password
359     *
360     * @throws ILSException
361     * @return mixed          Associative array of patron info on successful login,
362     * null on unsuccessful login.
363     */
364    public function patronLogin($username, $password)
365    {
366        // TODO: if username is a barcode, test to make sure it fits proper format
367        $enabled = $this->config['PATRONAPI']['enabled'] ?? false;
368        if ($enabled && strtolower($enabled) !== 'false') {
369            // use patronAPI to authenticate customer
370            $url = $this->config['PATRONAPI']['url'];
371
372            // build patronapi pin test request
373            $result = $this->sendRequest(
374                $url . urlencode($username) . '/' . urlencode($password) .
375                '/pintest'
376            );
377
378            // search for successful response of "RETCOD=0"
379            if (stripos($result, 'RETCOD=0') == -1) {
380                // pin did not match, can look up specific error to return
381                // more useful info.
382                return null;
383            }
384
385            // Pin did match, get patron information
386            $result = $this->sendRequest($url . urlencode($username) . '/dump');
387
388            // The following is taken and modified from patronapi.php by John Blyberg
389            // released under the GPL
390            $api_contents = trim(strip_tags($result));
391            $api_array_lines = explode("\n", $api_contents);
392            $api_data = ['PBARCODE' => false];
393
394            foreach ($api_array_lines as $api_line) {
395                $api_line = str_replace('p=', 'peq', $api_line);
396                $api_line_arr = explode('=', $api_line);
397                $regex_match = ["/\[(.*?)\]/","/\s/",'/#/'];
398                $regex_replace = ['','','NUM'];
399                $key = trim(
400                    preg_replace($regex_match, $regex_replace, $api_line_arr[0])
401                );
402                $api_data[$key] = trim($api_line_arr[1]);
403            }
404
405            if (!$api_data['PBARCODE']) {
406                // No barcode found, can look up specific error to return more
407                // useful info. This check needs to be modified to handle using
408                // III patron ids also.
409                return null;
410            }
411
412            // return patron info
413            $ret = [];
414            $ret['id'] = $api_data['PBARCODE']; // or should I return patron id num?
415            $names = explode(',', $api_data['PATRNNAME']);
416            $ret['firstname'] = $names[1];
417            $ret['lastname'] = $names[0];
418            $ret['cat_username'] = urlencode($username);
419            $ret['cat_password'] = urlencode($password);
420            $ret['email'] = $api_data['EMAILADDR'];
421            $ret['major'] = null;
422            $ret['college'] = $api_data['HOMELIBR'];
423            $ret['homelib'] = $api_data['HOMELIBR'];
424            // replace $ separator in III addresses with newline
425            $ret['address1'] = str_replace('$', ', ', $api_data['ADDRESS']);
426            $ret['address2'] = str_replace('$', ', ', $api_data['ADDRESS2']);
427            preg_match(
428                '/([0-9]{5}|[0-9]{5}-[0-9]{4})[ ]*$/',
429                $api_data['ADDRESS'],
430                $zipmatch
431            );
432            $ret['zip'] = $zipmatch[1]; //retrieve from address
433            $ret['phone'] = $api_data['TELEPHONE'];
434            $ret['phone2'] = $api_data['TELEPHONE2'];
435            // Should probably have a translation table for patron type
436            $ret['group'] = $api_data['PTYPE'];
437            $ret['expiration'] = $api_data['EXPDATE'];
438            // Only if agency module is enabled.
439            $ret['region'] = $api_data['AGENCY'];
440            return $ret;
441        } else {
442            // TODO: use screen scrape
443            return null;
444        }
445    }
446}