Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 257 |
|
0.00% |
0 / 11 |
CRAP | |
0.00% |
0 / 1 |
RestConnector | |
0.00% |
0 / 257 |
|
0.00% |
0 / 11 |
4556 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
query | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
6 | |||
getRecord | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
getInstitutionCode | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
performSearch | |
0.00% |
0 / 45 |
|
0.00% |
0 / 1 |
462 | |||
call | |
0.00% |
0 / 37 |
|
0.00% |
0 / 1 |
90 | |||
processResponse | |
0.00% |
0 / 93 |
|
0.00% |
0 / 1 |
342 | |||
processHighlighting | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
30 | |||
processDescription | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
getJWT | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
30 | |||
getUrl | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | /** |
4 | * Primo Central connector (REST API). |
5 | * |
6 | * PHP version 8 |
7 | * |
8 | * Copyright (C) Villanova University 2010. |
9 | * Copyright (C) The National Library of Finland 2023. |
10 | * |
11 | * This program is free software; you can redistribute it and/or modify |
12 | * it under the terms of the GNU General Public License version 2, |
13 | * as published by the Free Software Foundation. |
14 | * |
15 | * This program is distributed in the hope that it will be useful, |
16 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
17 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
18 | * GNU General Public License for more details. |
19 | * |
20 | * You should have received a copy of the GNU General Public License |
21 | * along with this program; if not, write to the Free Software |
22 | * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA |
23 | * |
24 | * @category VuFind |
25 | * @package Search |
26 | * @author Spencer Lamm <slamm1@swarthmore.edu> |
27 | * @author Anna Headley <aheadle1@swarthmore.edu> |
28 | * @author Chelsea Lobdell <clobdel1@swarthmore.edu> |
29 | * @author Demian Katz <demian.katz@villanova.edu> |
30 | * @author Ere Maijala <ere.maijala@helsinki.fi> |
31 | * @author Oliver Goldschmidt <o.goldschmidt@tuhh.de> |
32 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
33 | * @link https://vufind.org |
34 | */ |
35 | |
36 | namespace VuFindSearch\Backend\Primo; |
37 | |
38 | use Laminas\Session\Container as SessionContainer; |
39 | |
40 | use function array_key_exists; |
41 | use function in_array; |
42 | use function is_array; |
43 | use function strlen; |
44 | |
45 | /** |
46 | * Primo Central connector (REST API). |
47 | * |
48 | * @category VuFind |
49 | * @package Search |
50 | * @author Spencer Lamm <slamm1@swarthmore.edu> |
51 | * @author Anna Headley <aheadle1@swarthmore.edu> |
52 | * @author Chelsea Lobdell <clobdel1@swarthmore.edu> |
53 | * @author Demian Katz <demian.katz@villanova.edu> |
54 | * @author Ere Maijala <ere.maijala@helsinki.fi> |
55 | * @author Oliver Goldschmidt <o.goldschmidt@tuhh.de> |
56 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
57 | * @link https://vufind.org |
58 | */ |
59 | class RestConnector implements ConnectorInterface, \Laminas\Log\LoggerAwareInterface |
60 | { |
61 | use \VuFind\Log\LoggerAwareTrait; |
62 | use \VuFindSearch\Backend\Feature\ConnectorCacheTrait; |
63 | |
64 | /** |
65 | * HTTP client factory |
66 | * |
67 | * @var callable |
68 | */ |
69 | protected $clientFactory; |
70 | |
71 | /** |
72 | * Primo JWT API URL |
73 | * |
74 | * @var string |
75 | */ |
76 | protected $jwtUrl; |
77 | |
78 | /** |
79 | * Primo REST API search URL |
80 | * |
81 | * @var string |
82 | */ |
83 | protected $searchUrl; |
84 | |
85 | /** |
86 | * Institution code |
87 | * |
88 | * @var string |
89 | */ |
90 | protected $inst; |
91 | |
92 | /** |
93 | * Session container |
94 | * |
95 | * @var SessionContainer |
96 | */ |
97 | protected $session; |
98 | |
99 | /** |
100 | * Response for an empty search |
101 | * |
102 | * @var array |
103 | */ |
104 | protected static $emptyQueryResponse = [ |
105 | 'recordCount' => 0, |
106 | 'documents' => [], |
107 | 'facets' => [], |
108 | 'error' => 'empty_search_disallowed', |
109 | ]; |
110 | |
111 | /** |
112 | * Mappings from VuFind index names to Primo |
113 | * |
114 | * @var array |
115 | */ |
116 | protected $indexMappings = [ |
117 | 'AllFields' => 'any', |
118 | 'Title' => 'title', |
119 | 'Author' => 'creator', |
120 | 'Subject' => 'sub', |
121 | 'Abstract' => 'desc', |
122 | 'ISSN' => 'issn', |
123 | ]; |
124 | |
125 | /** |
126 | * Legacy sort mappings |
127 | * |
128 | * @var array |
129 | */ |
130 | protected $sortMappings = [ |
131 | 'scdate' => 'date', |
132 | 'screator' => 'author', |
133 | 'stitle' => 'title', |
134 | ]; |
135 | |
136 | /** |
137 | * Constructor |
138 | * |
139 | * Sets up the Primo API Client |
140 | * |
141 | * @param string $jwtUrl Primo JWT API URL |
142 | * @param string $searchUrl Primo REST API search URL |
143 | * @param string $instCode Institution code (used as view ID, i.e. the |
144 | * vid parameter unless specified in the URL) |
145 | * @param callable $clientFactory HTTP client factory |
146 | * @param SessionContainer $session Session container |
147 | */ |
148 | public function __construct( |
149 | string $jwtUrl, |
150 | string $searchUrl, |
151 | string $instCode, |
152 | callable $clientFactory, |
153 | SessionContainer $session |
154 | ) { |
155 | $this->jwtUrl = $jwtUrl; |
156 | $this->searchUrl = $searchUrl; |
157 | $this->inst = $instCode; |
158 | |
159 | $this->clientFactory = $clientFactory; |
160 | $this->session = $session; |
161 | } |
162 | |
163 | /** |
164 | * Execute a search. Adds all the querystring parameters into |
165 | * $this->client and returns the parsed response |
166 | * |
167 | * @param string $institution Institution |
168 | * @param array $terms Associative array: |
169 | * index string: primo index to search (default "any") |
170 | * lookfor string: actual search terms |
171 | * @param array $params Associative array of optional arguments: |
172 | * phrase bool: true if it's a quoted phrase (default false) |
173 | * onCampus bool: (default true) |
174 | * didyoumean bool: (default false) |
175 | * filterList array: (field, value) pairs to filter results (def null) |
176 | * pageNumber string: index of first record (default 1) |
177 | * limit string: number of records to return (default 20) |
178 | * sort string: value to be used by for sorting (default null) |
179 | * highlight bool: whether to highlight search term matches in records |
180 | * highlightStart string: Prefix for a highlighted term |
181 | * highlightEnd string: Suffix for a Highlighted term |
182 | * Anything in $params not listed here will be ignored. |
183 | * |
184 | * Note: some input parameters accepted by Primo are not implemented here: |
185 | * - dym (did you mean) |
186 | * - more (get more) |
187 | * - lang (specify input language so engine can do lang. recognition) |
188 | * - displayField (has to do with highlighting somehow) |
189 | * |
190 | * @throws \Exception |
191 | * @return array An array of query results |
192 | * |
193 | * @link http://www.exlibrisgroup.org/display/PrimoOI/Brief+Search |
194 | */ |
195 | public function query($institution, $terms, $params = null) |
196 | { |
197 | // defaults for params |
198 | $args = [ |
199 | 'phrase' => false, |
200 | 'onCampus' => true, |
201 | 'didYouMean' => false, |
202 | 'filterList' => null, |
203 | 'pcAvailability' => false, |
204 | 'pageNumber' => 1, |
205 | 'limit' => 20, |
206 | 'sort' => null, |
207 | 'highlight' => false, |
208 | 'highlightStart' => '', |
209 | 'highlightEnd' => '', |
210 | ]; |
211 | if (isset($params)) { |
212 | $args = array_merge($args, $params); |
213 | } |
214 | // Ensure limit is at least 1 since Primo seems to be flaky with 0: |
215 | $args['limit'] = max(1, $args['limit']); |
216 | |
217 | return $this->performSearch($terms, $args); |
218 | } |
219 | |
220 | /** |
221 | * Retrieves a document specified by the ID. |
222 | * |
223 | * @param string $recordId The document to retrieve from the Primo API |
224 | * @param ?string $inst_code Institution code (optional) |
225 | * @param bool $onCampus Whether the user is on campus |
226 | * |
227 | * @throws \Exception |
228 | * @return array An array of query results |
229 | */ |
230 | public function getRecord(string $recordId, $inst_code = null, $onCampus = false) |
231 | { |
232 | if ('' === $recordId) { |
233 | return self::$emptyQueryResponse; |
234 | } |
235 | // Query String Parameters |
236 | $qs = []; |
237 | // It would be tempting to use 'exact' matching here, but it does not work |
238 | // with all record IDs, so need to use 'contains'. Contrary to the old |
239 | // brief search API, quotes are necessary here for all IDs to work. |
240 | $qs['q'] = 'rid,contains,"' . str_replace(';', ' ', $recordId) . '"'; |
241 | $qs['offset'] = '0'; |
242 | $qs['limit'] = '1'; |
243 | // pcAvailability=true is needed for records, which |
244 | // are NOT in the PrimoCentral Holdingsfile. |
245 | // It won't hurt to have this parameter always set to true. |
246 | // But it'd hurt to have it not set in case you want to get |
247 | // a record, which is not in the Holdingsfile. |
248 | $qs['pcAvailability'] = 'true'; |
249 | |
250 | return $this->processResponse($this->call(http_build_query($qs))); |
251 | } |
252 | |
253 | /** |
254 | * Get the institution code based on user IP. If user is coming from |
255 | * off campus return |
256 | * |
257 | * @return string |
258 | */ |
259 | public function getInstitutionCode() |
260 | { |
261 | return $this->inst; |
262 | } |
263 | |
264 | /** |
265 | * Support method for query() -- perform inner search logic |
266 | * |
267 | * @param array $terms Associative array: |
268 | * index string: primo index to search (default "any") |
269 | * lookfor string: actual search terms |
270 | * @param array $args Associative array of optional arguments (see query method for more information) |
271 | * |
272 | * @throws \Exception |
273 | * @return array An array of query results |
274 | */ |
275 | protected function performSearch($terms, $args) |
276 | { |
277 | // we have to build a querystring because I think adding them |
278 | // incrementally is implemented as a dictionary, but we are allowed |
279 | // multiple querystring parameters with the same key. |
280 | $qs = []; |
281 | |
282 | // QUERYSTRING: query (search terms) |
283 | // re: phrase searches, turns out we can just pass whatever we got |
284 | // to primo and they will interpret it correctly. |
285 | // leaving this flag in b/c it's not hurting anything, but we |
286 | // don't currently have a situation where we need to use "exact" |
287 | $precision = $args['phrase'] ? 'exact' : 'contains'; |
288 | |
289 | $primoQuery = []; |
290 | if (is_array($terms)) { |
291 | foreach ($terms as $thisTerm) { |
292 | $lookfor = str_replace(';', ' ', $thisTerm['lookfor']); |
293 | if (!$lookfor) { |
294 | continue; |
295 | } |
296 | // Set the index to search |
297 | $index = $this->indexMappings[$thisTerm['index']] ?? 'any'; |
298 | |
299 | // Set precision |
300 | if (array_key_exists('op', $thisTerm) && !empty($thisTerm['op'])) { |
301 | $precision = $thisTerm['op']; |
302 | } |
303 | |
304 | $primoQuery[] = "$index,$precision,$lookfor"; |
305 | } |
306 | } |
307 | |
308 | // Return if we don't have any query terms: |
309 | if (!$primoQuery && empty($args['filterList'])) { |
310 | return self::$emptyQueryResponse; |
311 | } |
312 | |
313 | if ($primoQuery) { |
314 | $qs['q'] = implode(';', $primoQuery); |
315 | } |
316 | |
317 | // QUERYSTRING: query (filter list) |
318 | // Date-related TODO: |
319 | // - provide additional support / processing for [x to y] limits? |
320 | if (!empty($args['filterList'])) { |
321 | $multiFacets = []; |
322 | $qInclude = []; |
323 | $qExclude = []; |
324 | foreach ($args['filterList'] as $current) { |
325 | $facet = $current['field']; |
326 | $facetOp = $current['facetOp']; |
327 | $values = $current['values']; |
328 | |
329 | foreach ($values as $value) { |
330 | if ('OR' === $facetOp) { |
331 | $multiFacets[] = "facet_$facet,include,$value"; |
332 | } elseif ('NOT' === $facetOp) { |
333 | $qExclude[] = "facet_$facet,exact,$value"; |
334 | } else { |
335 | $qInclude[] = "facet_$facet,exact,$value"; |
336 | } |
337 | } |
338 | } |
339 | if ($multiFacets) { |
340 | $qs['multiFacets'] = implode('|,|', $multiFacets); |
341 | } |
342 | if ($qInclude) { |
343 | $qs['qInclude'] = implode('|,|', $qInclude); |
344 | } |
345 | if ($qExclude) { |
346 | $qs['qExclude'] = implode('|,|', $qExclude); |
347 | } |
348 | } |
349 | |
350 | // QUERYSTRING: pcAvailability |
351 | // by default, Primo Central only returns matches, |
352 | // which are available via Holdingsfile |
353 | // pcAvailability = false |
354 | // By setting this value to true, also matches, which |
355 | // are NOT available via Holdingsfile are returned |
356 | // (yes, right, set this to true - that's ExLibris Logic) |
357 | if ($args['pcAvailability']) { |
358 | $qs['pcAvailability'] = 'true'; |
359 | } |
360 | |
361 | // QUERYSTRING: offset and limit |
362 | $recordStart = ($args['pageNumber'] - 1) * $args['limit']; |
363 | $qs['offset'] = $recordStart; |
364 | $qs['limit'] = $args['limit']; |
365 | |
366 | // QUERYSTRING: sort |
367 | // Possible values are rank (default), title, author or date. |
368 | $sort = $args['sort'] ?? null; |
369 | if ($sort && 'relevance' !== $sort) { |
370 | // Map legacy sort options: |
371 | $qs['sort'] = $this->sortMappings[$sort] ?? $sort; |
372 | } |
373 | |
374 | return $this->processResponse($this->call(http_build_query($qs)), $args); |
375 | } |
376 | |
377 | /** |
378 | * Small wrapper for sendRequest, process to simplify error handling. |
379 | * |
380 | * @param string $qs Query string |
381 | * |
382 | * @return string Result body |
383 | * @throws \Exception |
384 | */ |
385 | protected function call(string $qs): string |
386 | { |
387 | $url = $this->getUrl($this->searchUrl); |
388 | $url .= (str_contains($url, '?') ? '&' : '?') . $qs; |
389 | $this->debug("GET: $url"); |
390 | $client = ($this->clientFactory)($url); |
391 | $client->setMethod('GET'); |
392 | // Check cache: |
393 | $resultBody = null; |
394 | $cacheKey = null; |
395 | if ($this->cache) { |
396 | $cacheKey = $this->getCacheKey($client); |
397 | $resultBody = $this->getCachedData($cacheKey); |
398 | } |
399 | if (null === $resultBody) { |
400 | if ($jwt = $this->getJWT()) { |
401 | $client->setHeaders( |
402 | [ |
403 | 'Authorization' => [ |
404 | "Bearer $jwt", |
405 | ], |
406 | ] |
407 | ); |
408 | } |
409 | // Send request: |
410 | $result = $client->send(); |
411 | if ($jwt && $result->getStatusCode() === 403) { |
412 | // Reset JWT and try again: |
413 | $jwt = $this->getJWT(true); |
414 | $client->setHeaders( |
415 | [ |
416 | 'Authorization' => [ |
417 | "Bearer $jwt", |
418 | ], |
419 | ] |
420 | ); |
421 | $result = $client->send(); |
422 | } |
423 | $resultBody = $result->getBody(); |
424 | if (!$result->isSuccess()) { |
425 | $this->logError("Request $url failed with error code " . $result->getStatusCode() . ": $resultBody"); |
426 | throw new \Exception($resultBody); |
427 | } |
428 | if ($cacheKey) { |
429 | $this->putCachedData($cacheKey, $resultBody); |
430 | } |
431 | } |
432 | return $resultBody; |
433 | } |
434 | |
435 | /** |
436 | * Translate Primo's JSON into array of arrays. |
437 | * |
438 | * @param string $data The raw xml from Primo |
439 | * @param array $params Request parameters |
440 | * |
441 | * @return array The processed response from Primo |
442 | */ |
443 | protected function processResponse(string $data, array $params = []): array |
444 | { |
445 | // Make sure data exists |
446 | if ('' === $data) { |
447 | throw new \Exception('Primo did not return any data'); |
448 | } |
449 | |
450 | // Parse API response |
451 | $response = json_decode($data); |
452 | |
453 | if (false === $response) { |
454 | throw new \Exception('Error while parsing Primo response'); |
455 | } |
456 | |
457 | $totalhits = (int)$response->info->total; |
458 | $items = []; |
459 | foreach ($response->docs as $doc) { |
460 | $item = []; |
461 | $pnx = $doc->pnx; |
462 | $addata = $pnx->addata; |
463 | $control = $pnx->control; |
464 | $display = $pnx->display; |
465 | $search = $pnx->search; |
466 | $item['recordid'] = substr($control->recordid[0], 3); |
467 | $item['title'] = $display->title[0] ?? ''; |
468 | $item['format'] = $display->type ?? []; |
469 | // creators (use the search fields instead of display to get them as an array instead of a long string) |
470 | if ($search->creator ?? null) { |
471 | $item['creator'] = array_map('trim', $search->creator); |
472 | } |
473 | // subjects (use the search fields instead of display to get them as an array instead of a long string) |
474 | if ($search->subject ?? null) { |
475 | $item['subjects'] = $search->subject; |
476 | } |
477 | $item['ispartof'] = $display->ispartof[0] ?? ''; |
478 | $item['description'] = $display->description[0] |
479 | ?? $search->description[0] |
480 | ?? ''; |
481 | // and the rest! |
482 | $item['language'] = $display->language[0] ?? ''; |
483 | $item['source'] = implode('; ', $display->source ?? []); |
484 | $item['identifier'] = $display->identifier[0] ?? ''; |
485 | $item['fulltext'] = $pnx->delivery->fulltext[0] ?? ''; |
486 | $item['issn'] = $search->issn ?? []; |
487 | $item['publisher'] = $display->publisher ?? []; |
488 | $item['peer_reviewed'] = ($display->lds50[0] ?? '') === 'peer_reviewed'; |
489 | $openurl = $pnx->links->openurl[0] ?? ''; |
490 | $item['url'] = $openurl && !str_starts_with($openurl, '$') |
491 | ? $openurl |
492 | : ($pnx->GetIt2->link ?? ''); |
493 | |
494 | $processCitations = function (array $data): array { |
495 | return array_map( |
496 | function ($s) { |
497 | return "cdi_$s"; |
498 | }, |
499 | $data |
500 | ); |
501 | }; |
502 | |
503 | // These require the cdi_ prefix in search, so add it right away: |
504 | $item['cites'] = $processCitations($display->cites ?? []); |
505 | $item['cited_by'] = $processCitations($display->citedby ?? []); |
506 | |
507 | // Container data |
508 | $item['container_title'] = $addata->jtitle[0] ?? ''; |
509 | $item['container_volume'] = $addata->volume[0] ?? ''; |
510 | $item['container_issue'] = $addata->issue[0] ?? ''; |
511 | $item['container_start_page'] = $addata->spage[0] ?? ''; |
512 | $item['container_end_page'] = $addata->epage[0] ?? ''; |
513 | foreach ($addata->eissn ?? [] as $eissn) { |
514 | if (!in_array($eissn, $item['issn'])) { |
515 | $item['issn'][] = $eissn; |
516 | } |
517 | } |
518 | foreach ($addata->issn ?? [] as $issn) { |
519 | if (!in_array($issn, $item['issn'])) { |
520 | $item['issn'][] = $issn; |
521 | } |
522 | } |
523 | $item['doi_str_mv'] = $addata->doi ?? []; |
524 | |
525 | // Remove dash-less ISSNs if there are corresponding dashed ones |
526 | // (We could convert dash-less ISSNs to dashed ones, but try to stay |
527 | // true to the metadata) |
528 | $callback = function ($issn) use ($item) { |
529 | return strlen($issn) != 8 |
530 | || !in_array( |
531 | substr($issn, 0, 4) . '-' . substr($issn, 4), |
532 | $item['issn'] |
533 | ); |
534 | }; |
535 | $item['issn'] = array_values(array_filter($item['issn'], $callback)); |
536 | |
537 | $this->processHighlighting($item, $params, $response->highlights); |
538 | |
539 | // Fix description now that highlighting is done: |
540 | $item['description'] = $this->processDescription($item['description']); |
541 | |
542 | $item['fullrecord'] = json_decode(json_encode($pnx), true); |
543 | $items[] = $item; |
544 | } |
545 | |
546 | // Add active filters to the facet list (Primo doesn't return them): |
547 | $facets = []; |
548 | foreach ($params['filterList'] ?? [] as $current) { |
549 | if ('NOT' === $current['facetOp']) { |
550 | continue; |
551 | } |
552 | $field = $current['field']; |
553 | foreach ($current['values'] as $value) { |
554 | $facets[$field][$value] = null; |
555 | } |
556 | } |
557 | |
558 | // Process received facets |
559 | foreach ($response->facets as $facet) { |
560 | // Handle facet values as strings to ensure that numeric values stay |
561 | // intact (no array_combine etc.): |
562 | foreach ($facet->values as $value) { |
563 | $facets[$facet->name][(string)$value->value] = $value->count; |
564 | } |
565 | uasort( |
566 | $facets[$facet->name], |
567 | function ($a, $b) { |
568 | // Put the selected facets (with null as value) on the top: |
569 | return ($b ?? PHP_INT_MAX) <=> ($a ?? PHP_INT_MAX); |
570 | } |
571 | ); |
572 | } |
573 | |
574 | // Apparently there's no "did you mean" data in the response.. |
575 | |
576 | return [ |
577 | 'recordCount' => $totalhits, |
578 | 'documents' => $items, |
579 | 'facets' => $facets, |
580 | 'didYouMean' => [], |
581 | 'error' => $response->info->errorDetails->errorMessages[0] ?? [], |
582 | ]; |
583 | } |
584 | |
585 | /** |
586 | * Process highlighting tags of the record fields |
587 | * |
588 | * @param array $record Record data |
589 | * @param array $params Request params |
590 | * @param \StdClass $highlight Highlighting data |
591 | * |
592 | * @return void |
593 | */ |
594 | protected function processHighlighting(array &$record, array $params, \StdClass $highlight): void |
595 | { |
596 | if (empty($params['highlight'])) { |
597 | return; |
598 | } |
599 | |
600 | $startTag = $params['highlightStart'] ?? ''; |
601 | $endTag = $params['highlightEnd'] ?? ''; |
602 | |
603 | $highlightFields = [ |
604 | 'title' => 'title', |
605 | 'creator' => 'author', |
606 | 'description' => 'description', |
607 | ]; |
608 | |
609 | $hilightDetails = []; |
610 | |
611 | foreach ($highlightFields as $primoField => $field) { |
612 | if ( |
613 | ($highlightValues = $highlight->$primoField ?? null) |
614 | && !empty($record[$field]) |
615 | ) { |
616 | $match = implode( |
617 | '|', |
618 | array_map( |
619 | function ($s) { |
620 | return preg_quote($s, '/'); |
621 | }, |
622 | $highlightValues |
623 | ) |
624 | ); |
625 | $hilightDetails[$field] = array_map( |
626 | function ($s) use ($match, $startTag, $endTag) { |
627 | return preg_replace("/(\b|-|–)($match)(\b|-|–)/", "$1$startTag$2$endTag$3", $s); |
628 | }, |
629 | (array)$record[$field] |
630 | ); |
631 | } |
632 | } |
633 | |
634 | $record['highlightDetails'] = $hilightDetails; |
635 | } |
636 | |
637 | /** |
638 | * Fix the description field by removing tags etc. |
639 | * |
640 | * @param string $description Description |
641 | * |
642 | * @return string |
643 | */ |
644 | protected function processDescription($description) |
645 | { |
646 | // Sometimes the entire article is in the description, so just take a chunk |
647 | // from the beginning. |
648 | $description = trim(mb_substr($description, 0, 2500, 'UTF-8')); |
649 | // These may contain all kinds of metadata, and just stripping |
650 | // tags mushes it all together confusingly. |
651 | $description = str_replace('<P>', '<p>', $description); |
652 | $paragraphs = explode('<p>', $description); |
653 | foreach ($paragraphs as &$value) { |
654 | // Strip tags, trim so array_filter can get rid of |
655 | // entries that would just have spaces |
656 | $value = trim(strip_tags($value)); |
657 | } |
658 | $paragraphs = array_filter($paragraphs); |
659 | // Now join paragraphs using line breaks |
660 | return implode('<br>', $paragraphs); |
661 | } |
662 | |
663 | /** |
664 | * Get a JWT token for the session |
665 | * |
666 | * @param bool $renew Whether to renew the token |
667 | * |
668 | * @return string |
669 | */ |
670 | protected function getJWT(bool $renew = false): string |
671 | { |
672 | if (!$this->jwtUrl) { |
673 | return ''; |
674 | } |
675 | |
676 | if (!$renew && isset($this->session->jwt)) { |
677 | return $this->session->jwt; |
678 | } |
679 | $client = ($this->clientFactory)($this->getUrl($this->jwtUrl)); |
680 | $result = $client->setMethod('GET')->send(); |
681 | $resultBody = $result->getBody(); |
682 | if (!$result->isSuccess()) { |
683 | $this->logError( |
684 | "Request {$this->jwtUrl} failed with error code " . $result->getStatusCode() . ": $resultBody" |
685 | ); |
686 | throw new \Exception($resultBody); |
687 | } |
688 | $this->session->jwt = trim($resultBody, '"'); |
689 | return $this->session->jwt; |
690 | } |
691 | |
692 | /** |
693 | * Build a URL from a configured one |
694 | * |
695 | * @param string $url URL |
696 | * |
697 | * @return string |
698 | */ |
699 | protected function getUrl(string $url): string |
700 | { |
701 | return str_replace('{{INSTCODE}}', urlencode($this->inst), $url); |
702 | } |
703 | } |