Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.34% covered (success)
94.34%
100 / 106
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jop
94.34% covered (success)
94.34%
100 / 106
50.00% covered (danger)
50.00%
4 / 8
30.16
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
 fetchLinks
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 parseLinks
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
2.00
 getResolverUrl
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 supportsMoreOptionsLink
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 downgradeOpenUrl
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
8.38
 getElectronicResults
94.74% covered (success)
94.74%
36 / 38
0.00% covered (danger)
0.00%
0 / 1
8.01
 getPrintResults
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3/**
4 * JOP Link Resolver Driver
5 *
6 * JOP is a free service -- the API endpoint is available at
7 * http://services.dnb.de/fize-service/gvr/full.xml
8 *
9 * API documentation is available at
10 * http://www.zeitschriftendatenbank.de/services/journals-online-print
11 *
12 * PHP version 8
13 *
14 * Copyright (C) Markus Fischer, info@flyingfischer.ch
15 *
16 * last update: 2011-04-13
17 *
18 * This program is free software; you can redistribute it and/or modify
19 * it under the terms of the GNU General Public License version 2,
20 * as published by the Free Software Foundation.
21 *
22 * This program is distributed in the hope that it will be useful,
23 * but WITHOUT ANY WARRANTY; without even the implied warranty of
24 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
25 * GNU General Public License for more details.
26 *
27 * You should have received a copy of the GNU General Public License
28 * along with this program; if not, write to the Free Software
29 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
30 *
31 * @category VuFind
32 * @package  Resolver_Drivers
33 * @author   Markus Fischer <info@flyingfischer.ch>
34 * @author   André Lahmann <lahmann@ub.uni-leipzig.de>
35 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
36 * @link     https://vufind.org/wiki/development:plugins:link_resolver_drivers Wiki
37 */
38
39namespace VuFind\Resolver\Driver;
40
41use DOMDocument;
42use DOMXpath;
43use VuFind\Net\UserIpReader;
44
45use function in_array;
46
47/**
48 * JOP Link Resolver Driver
49 *
50 * @category VuFind
51 * @package  Resolver_Drivers
52 * @author   Markus Fischer <info@flyingfischer.ch>
53 * @author   André Lahmann <lahmann@ub.uni-leipzig.de>
54 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
55 * @link     https://vufind.org/wiki/development:plugins:link_resolver_drivers Wiki
56 */
57class Jop extends AbstractBase
58{
59    /**
60     * As the JOP resolver provides also generic labels 'Article', 'Journal'
61     * etc. in element AccessLevel this label can be used as title for
62     * resolver results by setting this variable to 'AccessLevel'
63     *
64     * @var string
65     */
66    protected $xpathTitleSelector = 'Title';
67
68    /**
69     * HTTP client
70     *
71     * @var \Laminas\Http\Client
72     */
73    protected $httpClient;
74
75    /**
76     * User IP address reader
77     *
78     * @var UserIpReader
79     */
80    protected $userIpReader;
81
82    /**
83     * Constructor
84     *
85     * @param string               $baseUrl      Base URL for link resolver
86     * @param \Laminas\Http\Client $httpClient   HTTP client
87     * @param UserIpReader         $userIpReader User IP address reader
88     */
89    public function __construct(
90        $baseUrl,
91        \Laminas\Http\Client $httpClient,
92        UserIpReader $userIpReader
93    ) {
94        parent::__construct($baseUrl);
95        $this->httpClient = $httpClient;
96        $this->userIpReader = $userIpReader;
97    }
98
99    /**
100     * Fetch Links
101     *
102     * Fetches a set of links corresponding to an OpenURL
103     *
104     * @param string $openURL openURL (url-encoded)
105     *
106     * @return string         raw XML returned by resolver
107     */
108    public function fetchLinks($openURL)
109    {
110        // Get the actual resolver url for the given openUrl
111        $url = $this->getResolverUrl($openURL);
112
113        // Make the call to the JOP and load results
114        $feed = $this->httpClient->setUri($url)->send()->getBody();
115        return $feed;
116    }
117
118    /**
119     * Parse Links
120     *
121     * Parses an XML file returned by a link resolver
122     * and converts it to a standardised format for display
123     *
124     * @param string $xmlstr Raw XML returned by resolver
125     *
126     * @return array         Array of values
127     */
128    public function parseLinks($xmlstr)
129    {
130        $records = []; // array to return
131
132        $xml = new DOMDocument();
133        if (!@$xml->loadXML($xmlstr)) {
134            return $records;
135        }
136
137        $xpath = new DOMXpath($xml);
138
139        // get results for online
140        $this->getElectronicResults('0', 'Free', $records, $xpath);
141        $this->getElectronicResults('1', 'Partially free', $records, $xpath);
142        $this->getElectronicResults('2', 'Licensed', $records, $xpath);
143        $this->getElectronicResults('3', 'Partially licensed', $records, $xpath);
144        $this->getElectronicResults('4', 'Not free', $records, $xpath);
145        $this->getElectronicResults('10', 'Unknown Electronic', $records, $xpath);
146
147        // get results for print, only if available
148        $this->getPrintResults('2', 'Print available', $records, $xpath);
149        $this->getPrintResults('3', 'Print partially available', $records, $xpath);
150        $this->getPrintResults('10', 'Unknown Print', $records, $xpath);
151
152        return $records;
153    }
154
155    /**
156     * Get Resolver Url
157     *
158     * Transform the OpenURL as needed to get a working link to the resolver.
159     *
160     * @param string $openURL openURL (url-encoded)
161     *
162     * @return string Link
163     */
164    public function getResolverUrl($openURL)
165    {
166        // Unfortunately the JOP-API only allows OpenURL V0.1 and
167        // breaks when sending a non expected parameter.
168        // So we do have to 'downgrade' the OpenURL-String from V1.0 to V0.1
169        // and exclude all parameters that are not compliant with the JOP.
170
171        // Parse OpenURL into associative array:
172        $tmp = explode('&', $openURL);
173        $parsed = [];
174
175        foreach ($tmp as $current) {
176            $tmp2 = explode('=', $current, 2);
177            $parsed[$tmp2[0]] = $tmp2[1] ?? null;
178        }
179
180        // Downgrade 1.0 to 0.1
181        if ($parsed['ctx_ver'] ?? null == 'Z39.88-2004') {
182            $openURL = $this->downgradeOpenUrl($parsed);
183        }
184
185        // make the request IP-based to allow automatic
186        // indication on institution level
187        $ipAddr = $this->userIpReader->getUserIp();
188        $openURL .= '&pid=client_ip%3D' . urlencode($ipAddr);
189
190        // Make the call to the JOP and load results
191        $url = $this->baseUrl . '?' . $openURL;
192
193        return $url;
194    }
195
196    /**
197     * Allows for resolver driver specific enabling/disabling of the more options
198     * link which will link directly to the resolver URL. This should return false if
199     * the resolver returns data in XML or any other human unfriendly response.
200     *
201     * @return bool
202     */
203    public function supportsMoreOptionsLink()
204    {
205        // the JOP link resolver returns unstyled XML which is not helpful for the
206        // user
207        return false;
208    }
209
210    /**
211     * Downgrade an OpenURL from v1.0 to v0.1 for compatibility with JOP.
212     *
213     * @param array $parsed Array of parameters parsed from the OpenURL.
214     *
215     * @return string       JOP-compatible v0.1 OpenURL
216     */
217    protected function downgradeOpenUrl($parsed)
218    {
219        // we need 'genre' but only the values
220        // article or journal are allowed...
221        $downgraded[] = 'genre=article';
222
223        // prepare content for downgrading
224        // resolver only accepts date formats YYYY, YYYY-MM, and YYYY-MM-DD
225        // in case we have a date in another format, drop the date information
226        if (
227            isset($parsed['rft.date'])
228            && !preg_match('/^\d{4}(-\d\d(-\d\d)?)?$/', $parsed['rft.date'])
229        ) {
230            unset($parsed['rft.date']);
231        }
232
233        $map = [
234            'rfr_id' => 'sid',
235            'rft.date' => 'date',
236            'rft.issn' => 'issn',
237            'rft.isbn' => 'isbn', // isbn is supported as of 12/2021
238            'rft.volume' => 'volume',
239            'rft.issue' => 'issue',
240            'rft.spage' => 'spage',
241            'rft.pages' => 'pages',
242        ];
243
244        // ignore all other parameters
245        foreach ($parsed as $key => $value) {
246            // exclude empty parameters
247            if (isset($value) && $value !== '') {
248                if (isset($map[$key])) {
249                    $downgraded[] = "{$map[$key]}=$value";
250                } elseif (in_array($key, $map)) {
251                    $downgraded[] = "$key=$value";
252                }
253            }
254        }
255
256        return implode('&', $downgraded);
257    }
258
259    /**
260     * Extract electronic results from the JOP response and inject them into the
261     * $records array.
262     *
263     * @param string   $state    The state attribute value to extract
264     * @param string   $coverage The coverage string to associate with the state
265     * @param array    $records  The array of results to update
266     * @param DOMXpath $xpath    The XPath object containing parsed XML
267     *
268     * @return void
269     */
270    protected function getElectronicResults($state, $coverage, &$records, $xpath)
271    {
272        $results = $xpath->query(
273            '/OpenURLResponseXML/Full/ElectronicData/ResultList/Result[@state=' .
274            $state . ']'
275        );
276
277        /*
278         * possible state values:
279         * -1 ISSN nicht eindeutig
280         *  0 Standort-unabhängig frei zugänglich
281         *  1 Standort-unabhängig teilweise zugänglich (Unschärfe bedingt durch
282         *    unspezifische Anfrage oder Moving-Wall)
283         *  2 Lizenziert
284         *  3 Für gegebene Bibliothek teilweise lizenziert (Unschärfe bedingt durch
285         *    unspezifische Anfrage oder Moving-Wall)
286         *  4 nicht lizenziert
287         *  5 Zeitschrift gefunden
288         *    Angaben über Erscheinungsjahr, Datum ... liegen außerhalb des
289         *    hinterlegten bibliothekarischen Zeitraums
290         * 10 Unbekannt (ISSN unbekannt, Bibliothek unbekannt)
291         */
292        $state_access_mapping = [
293            '-1' => 'error',
294            '0'  => 'open',
295            '1'  => 'open',
296            '2'  => 'limited',
297            '3'  => 'limited',
298            '4'  => 'denied',
299            '5'  => 'denied',
300            '10' => 'unknown',
301        ];
302
303        $i = 0;
304        foreach ($results as $result) {
305            $record = [];
306
307            // get title from XPath Element defined in $xpathTitleSelector
308            $titleXP = '/OpenURLResponseXML/Full/ElectronicData/ResultList/' .
309                "Result[@state={$state}][" . ($i + 1) . ']/' .
310                $this->xpathTitleSelector;
311            $title = $xpath->query($titleXP, $result)->item(0);
312            if (isset($title)) {
313                $record['title'] = strip_tags($title->nodeValue);
314            }
315
316            // get additional coverage information
317            $additionalXP = '/OpenURLResponseXML/Full/ElectronicData/ResultList/' .
318                "Result[@state={$state}][" . ($i + 1) . ']/Additionals/Additional';
319            $additionalType = ['nali', 'intervall', 'moving_wall'];
320            $additionals = [];
321            foreach ($additionalType as $type) {
322                $additional = $xpath
323                    ->query($additionalXP . "[@type='" . $type . "']", $result)
324                    ->item(0);
325                if (isset($additional->nodeValue)) {
326                    $additionals[$type] = strip_tags($additional->nodeValue);
327                }
328            }
329            $record['coverage']
330                = !empty($additionals) ? implode('; ', $additionals) : $coverage;
331
332            $record['access'] = $state_access_mapping[$state];
333
334            // try to find direct access URL
335            $accessUrlXP = '/OpenURLResponseXML/Full/ElectronicData/ResultList/' .
336                "Result[@state={$state}][" . ($i + 1) . ']/AccessURL';
337            $accessUrl = $xpath->query($accessUrlXP, $result)->item(0);
338
339            // try to find journal URL as fallback for direct access URL
340            $journalUrlXP = '/OpenURLResponseXML/Full/ElectronicData/ResultList/' .
341                "Result[@state={$state}][" . ($i + 1) . ']/JournalURL';
342            $journalUrl = $xpath->query($journalUrlXP, $result)->item(0);
343
344            // return direct access URL if available otherwise journal URL fallback
345            if (isset($accessUrl->nodeValue)) {
346                $record['href'] = $accessUrl->nodeValue;
347            } elseif (isset($journalUrl)) {
348                $record['href'] = $journalUrl->nodeValue;
349            }
350            // Service type needs to be hard-coded for calling code to properly
351            // categorize links. The commented code below picks a more appropriate
352            // value but won't work for now -- retained for future reference.
353            //$service_typeXP = "/OpenURLResponseXML/Full/ElectronicData/ResultList/"
354            //    . "Result[@state={$state}][".($i+1)."]/AccessLevel";
355            //$record['service_type']
356            //    = $xpath->query($service_typeXP, $result)->item(0)->nodeValue;
357            $record['service_type'] = 'getFullTxt';
358            array_push($records, $record);
359            $i++;
360        }
361    }
362
363    /**
364     * Extract print results from the JOP response and inject them into the
365     * $records array.
366     *
367     * @param string   $state    The state attribute value to extract
368     * @param string   $coverage The coverage string to associate with the state
369     * @param array    $records  The array of results to update
370     * @param DOMXpath $xpath    The XPath object containing parsed XML
371     *
372     * @return void
373     */
374    protected function getPrintResults($state, $coverage, &$records, $xpath)
375    {
376        $results = $xpath->query(
377            "/OpenURLResponseXML/Full/PrintData/ResultList/Result[@state={$state}]"
378        );
379
380        /*
381         * possible state values:
382         * -1 ISSN nicht eindeutig
383         *  2 Vorhanden
384         *  3 Teilweise vorhanden (Unschärfe bedingt durch unspezifische Anfrage bei
385         *    nicht vollständig vorhandener Zeitschrift)
386         *  4 Nicht vorhanden
387         * 10 Unbekannt (ZDB-ID unbekannt, ISSN unbekannt, Bibliothek unbekannt)
388         */
389        $state_access_mapping = [
390            '-1' => 'error',
391            '2'  => 'open',
392            '3'  => 'limited',
393            '4'  => 'denied',
394            '10' => 'unknown',
395        ];
396
397        $i = 0;
398        foreach ($results as $result) {
399            $record = [];
400            $record['title'] = $coverage;
401
402            $resultXP = '/OpenURLResponseXML/Full/PrintData/ResultList/' .
403                "Result[@state={$state}][" . ($i + 1) . ']';
404            $resultElements = [
405                'Title', 'Location', 'Signature', 'Period', 'Holding_comment',
406            ];
407            $elements = [];
408            foreach ($resultElements as $element) {
409                $elem = $xpath->query($resultXP . '/' . $element, $result)->item(0);
410                if (isset($elem->nodeValue)) {
411                    $elements[$element] = strip_tags($elem->nodeValue);
412                }
413            }
414            $record['coverage']
415                = !empty($elements) ? implode('; ', $elements) : $coverage;
416
417            $record['access'] = $state_access_mapping[$state];
418
419            $urlXP = '/OpenURLResponseXML/Full/PrintData/References/Reference/URL';
420            $url = $xpath->query($urlXP, $result)->item($i);
421            if (isset($url->nodeValue)) {
422                $record['href'] = $url->nodeValue;
423            }
424            // Service type needs to be hard-coded for calling code to properly
425            // categorize links. The commented code below picks a more appropriate
426            // value but won't work for now -- retained for future reference.
427            //$service_typeXP = "/OpenURLResponseXML/Full/PrintData/References"
428            //    . "/Reference/Label";
429            //$record['service_type']
430            //    = $xpath->query($service_typeXP, $result)->item($i)->nodeValue;
431            $record['service_type'] = 'getHolding';
432            array_push($records, $record);
433            $i++;
434        }
435    }
436}