Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
88.41% |
145 / 164 |
|
43.75% |
7 / 16 |
CRAP | |
0.00% |
0 / 1 |
RecordDataFormatter | |
88.41% |
145 / 164 |
|
43.75% |
7 / 16 |
75.19 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
__invoke | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
sortCallback | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
allowValue | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
6 | |||
render | |
89.47% |
17 / 19 |
|
0.00% |
0 / 1 |
8.07 | |||
getData | |
82.35% |
14 / 17 |
|
0.00% |
0 / 1 |
7.27 | |||
getDefaults | |
81.82% |
9 / 11 |
|
0.00% |
0 / 1 |
6.22 | |||
setDefaults | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
3.33 | |||
addOptions | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
4 | |||
extractData | |
57.14% |
8 / 14 |
|
0.00% |
0 / 1 |
10.86 | |||
renderMulti | |
93.33% |
14 / 15 |
|
0.00% |
0 / 1 |
5.01 | |||
renderRecordHelper | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
3.07 | |||
renderRecordDriverTemplate | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
2.01 | |||
getLink | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
renderCombineAlt | |
90.91% |
20 / 22 |
|
0.00% |
0 / 1 |
6.03 | |||
renderSimple | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
5 |
1 | <?php |
2 | |
3 | /** |
4 | * Record driver data formatting view helper |
5 | * |
6 | * PHP version 8 |
7 | * |
8 | * Copyright (C) Villanova University 2016. |
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 View_Helpers |
25 | * @author Demian Katz <demian.katz@villanova.edu> |
26 | * @author Juha Luoma <juha.luoma@helsinki.fi> |
27 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
28 | * @link https://vufind.org/wiki/development:architecture:record_data_formatter |
29 | * Wiki |
30 | */ |
31 | |
32 | namespace VuFind\View\Helper\Root; |
33 | |
34 | use Laminas\View\Helper\AbstractHelper; |
35 | use VuFind\RecordDriver\AbstractBase as RecordDriver; |
36 | |
37 | use function call_user_func; |
38 | use function count; |
39 | use function is_array; |
40 | use function is_callable; |
41 | |
42 | /** |
43 | * Record driver data formatting view helper |
44 | * |
45 | * @category VuFind |
46 | * @package View_Helpers |
47 | * @author Demian Katz <demian.katz@villanova.edu> |
48 | * @author Juha Luoma <juha.luoma@helsinki.fi> |
49 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
50 | * @link https://vufind.org/wiki/development:architecture:record_data_formatter |
51 | * Wiki |
52 | */ |
53 | class RecordDataFormatter extends AbstractHelper |
54 | { |
55 | /** |
56 | * Default settings. |
57 | * |
58 | * @var array |
59 | */ |
60 | protected $defaults = []; |
61 | |
62 | /** |
63 | * Record driver object. |
64 | * |
65 | * @var RecordDriver |
66 | */ |
67 | protected $driver = null; |
68 | |
69 | /** |
70 | * Config. |
71 | * |
72 | * @var \Laminas\Config\Config |
73 | */ |
74 | protected $config; |
75 | |
76 | /** |
77 | * Constructor |
78 | * |
79 | * @param ?\Laminas\Config\Config $config Config |
80 | */ |
81 | public function __construct($config = null) |
82 | { |
83 | $this->config = $config; |
84 | } |
85 | |
86 | /** |
87 | * Store a record driver object and return this object so that the appropriate |
88 | * data can be rendered. |
89 | * |
90 | * @param RecordDriver $driver Record driver object. |
91 | * |
92 | * @return RecordDataFormatter |
93 | */ |
94 | public function __invoke(RecordDriver $driver = null): RecordDataFormatter |
95 | { |
96 | $this->driver = $driver; |
97 | return $this; |
98 | } |
99 | |
100 | /** |
101 | * Sort callback for field specification. |
102 | * |
103 | * @param array $a First value to compare |
104 | * @param array $b Second value to compare |
105 | * |
106 | * @return int |
107 | */ |
108 | protected function sortCallback($a, $b) |
109 | { |
110 | // Sort on 'pos' with 'label' as tie-breaker. |
111 | return ($a['pos'] == $b['pos']) |
112 | ? $a['label'] <=> $b['label'] |
113 | : $a['pos'] <=> $b['pos']; |
114 | } |
115 | |
116 | /** |
117 | * Should we allow a value? (Always accepts non-empty values; for empty |
118 | * values, allows zero when configured to do so). |
119 | * |
120 | * @param mixed $value Data to check for zero value. |
121 | * @param array $options Rendering options. |
122 | * @param array $ignoreCombineAlt If value should always be allowed when renderType is CombineAlt |
123 | * |
124 | * @return bool |
125 | */ |
126 | protected function allowValue($value, $options, $ignoreCombineAlt = false) |
127 | { |
128 | if (!empty($value) || ($ignoreCombineAlt && ($options['renderType'] ?? 'Simple') == 'CombineAlt')) { |
129 | return true; |
130 | } |
131 | $allowZero = $options['allowZero'] ?? true; |
132 | return $allowZero && ($value === 0 || $value === '0'); |
133 | } |
134 | |
135 | /** |
136 | * Return rendered text (or null if nothing to render). |
137 | * |
138 | * @param string $field Field being rendered (i.e. default label) |
139 | * @param mixed $data Data to render |
140 | * @param array $options Rendering options |
141 | * |
142 | * @return ?array |
143 | */ |
144 | protected function render($field, $data, $options) |
145 | { |
146 | if (!($options['enabled'] ?? true)) { |
147 | return null; |
148 | } |
149 | |
150 | // Check whether the data is worth rendering. |
151 | if (!$this->allowValue($data, $options, true)) { |
152 | return null; |
153 | } |
154 | |
155 | // Determine the rendering method to use, and bail out if it's illegal: |
156 | $method = empty($options['renderType']) |
157 | ? 'renderSimple' : 'render' . $options['renderType']; |
158 | if (!is_callable([$this, $method])) { |
159 | return null; |
160 | } |
161 | |
162 | // If the value evaluates false, we should double-check our zero handling: |
163 | $value = $this->$method($data, $options); |
164 | if (!$this->allowValue($value, $options)) { |
165 | return null; |
166 | } |
167 | |
168 | // Special case: if we received an array rather than a string, we should |
169 | // return it as-is (it probably came from renderMulti()). |
170 | if (is_array($value)) { |
171 | return $value; |
172 | } |
173 | |
174 | // Allow dynamic label override: |
175 | $label = is_callable($options['labelFunction'] ?? null) |
176 | ? call_user_func($options['labelFunction'], $data, $this->driver) |
177 | : $field; |
178 | $context = $options['context'] ?? []; |
179 | $pos = $options['pos'] ?? 0; |
180 | return [compact('label', 'value', 'context', 'pos')]; |
181 | } |
182 | |
183 | /** |
184 | * Create formatted key/value data based on a record driver and field spec. |
185 | * The first argument can be a descendant of RecordDriver. |
186 | * If omitted, then invoke this class with the desired driver. |
187 | * The second or first argument is an array containing formatting specifications. |
188 | * |
189 | * @param array ...$args Record driver object and/or formatting specifications. |
190 | * |
191 | * @return array |
192 | */ |
193 | public function getData(...$args) |
194 | { |
195 | if ($args[0] instanceof RecordDriver) { |
196 | $this->driver = $args[0]; |
197 | array_shift($args); |
198 | } |
199 | if (empty($args[0])) { |
200 | return []; |
201 | } |
202 | if (null === $this->driver) { |
203 | throw new \Exception('No driver set in RecordDataFormatter'); |
204 | } |
205 | if (!is_array($args[0])) { |
206 | throw new \Exception('Argument 0 must be an array'); |
207 | } |
208 | // Apply the spec: |
209 | $result = []; |
210 | foreach ($args[0] as $field => $current) { |
211 | // Extract the relevant data from the driver and try to render it. |
212 | $data = $this->extractData($current); |
213 | $value = $this->render($field, $data, $current); |
214 | if ($value !== null) { |
215 | $result = array_merge($result, $value); |
216 | } |
217 | } |
218 | // Sort the result: |
219 | usort($result, [$this, 'sortCallback']); |
220 | return $result; |
221 | } |
222 | |
223 | /** |
224 | * Get default configuration. |
225 | * |
226 | * @param string $key Key for configuration to look up. |
227 | * |
228 | * @return array |
229 | */ |
230 | public function getDefaults($key) |
231 | { |
232 | // No value stored? Return empty array: |
233 | if (!isset($this->defaults[$key])) { |
234 | return []; |
235 | } |
236 | // Callback stored? Resolve to array on demand: |
237 | if (is_callable($this->defaults[$key])) { |
238 | $this->defaults[$key] = $this->defaults[$key](); |
239 | if (!is_array($this->defaults[$key])) { |
240 | throw new \Exception('Callback for ' . $key . ' must return array'); |
241 | } |
242 | } |
243 | // Adding defaults from config |
244 | foreach ($this->config->Defaults->$key ?? [] as $field) { |
245 | $this->defaults[$key][$field] = []; |
246 | } |
247 | // Adding options from config |
248 | foreach ($this->defaults[$key] as $field => $options) { |
249 | $this->defaults[$key][$field] = $this->addOptions($key, $field, $options); |
250 | } |
251 | // Send back array: |
252 | return $this->defaults[$key]; |
253 | } |
254 | |
255 | /** |
256 | * Set default configuration. |
257 | * |
258 | * @param string $key Key for configuration to set. |
259 | * @param array|callable $values Defaults to store (either an array, or a |
260 | * callable returning an array). |
261 | * |
262 | * @return void |
263 | */ |
264 | public function setDefaults($key, $values) |
265 | { |
266 | if (!is_array($values) && !is_callable($values)) { |
267 | throw new \Exception('$values must be array or callable'); |
268 | } |
269 | $this->defaults[$key] = $values; |
270 | } |
271 | |
272 | /** |
273 | * Add global and configured options to options of a field. |
274 | * |
275 | * @param string $context Context of the field. |
276 | * @param string $field Field |
277 | * @param array $options Options of a field. |
278 | * |
279 | * @return ?array |
280 | */ |
281 | protected function addOptions($context, $field, $options) |
282 | { |
283 | if ($globalOptions = ($this->config->Global ?? false)) { |
284 | $options = array_filter($options, function ($val) { |
285 | return $val !== null; |
286 | }); |
287 | $options = array_merge($globalOptions->toArray(), $options); |
288 | } |
289 | |
290 | $section = 'Field_' . $field; |
291 | if ($fieldOptions = ($this->config->$section ?? false)) { |
292 | $fieldOptions = array_filter($fieldOptions->toArray(), function ($val) { |
293 | return $val !== null; |
294 | }); |
295 | $options = array_merge($options, $fieldOptions); |
296 | } |
297 | |
298 | $contextSection = $options['overrideContext'][$context] ?? false; |
299 | if ( |
300 | $contextOptions = $this->config->$contextSection ?? false |
301 | ) { |
302 | $contextOptions = array_filter($contextOptions->toArray(), function ($val) { |
303 | return $val !== null; |
304 | }); |
305 | $options = array_merge($options, $contextOptions); |
306 | } |
307 | |
308 | return $options; |
309 | } |
310 | |
311 | /** |
312 | * Extract data (usually from the record driver). |
313 | * |
314 | * @param array $options Incoming options |
315 | * |
316 | * @return mixed |
317 | */ |
318 | protected function extractData(array $options) |
319 | { |
320 | // Static cache for persisting data. |
321 | static $cache = []; |
322 | |
323 | // If $method is a bool, return it as-is; this allows us to force the |
324 | // rendering (or non-rendering) of particular data independent of the |
325 | // record driver. |
326 | $method = $options['dataMethod'] ?? false; |
327 | if ($method === true || $method === false) { |
328 | return $method; |
329 | } |
330 | |
331 | if ($useCache = ($options['useCache'] ?? false)) { |
332 | $cacheKey = $this->driver->getUniqueID() . '|' |
333 | . $this->driver->getSourceIdentifier() . '|' . $method |
334 | . (isset($options['dataMethodParams']) ? '|' . serialize($options['dataMethodParams']) : ''); |
335 | if (isset($cache[$cacheKey])) { |
336 | return $cache[$cacheKey]; |
337 | } |
338 | } |
339 | |
340 | // Default action: try to extract data from the record driver: |
341 | $data = $this->driver->tryMethod($method, $options['dataMethodParams'] ?? []); |
342 | |
343 | if ($useCache) { |
344 | $cache[$cacheKey] = $data; |
345 | } |
346 | |
347 | return $data; |
348 | } |
349 | |
350 | /** |
351 | * Render multiple lines for a single set of data. |
352 | * |
353 | * @param mixed $data Data to render |
354 | * @param array $options Rendering options. |
355 | * |
356 | * @return array |
357 | */ |
358 | protected function renderMulti( |
359 | $data, |
360 | array $options |
361 | ) { |
362 | // Make sure we have a callback for sorting the $data into groups... |
363 | $callback = $options['multiFunction'] ?? null; |
364 | if (!is_callable($callback)) { |
365 | throw new \Exception('Invalid multiFunction callback.'); |
366 | } |
367 | |
368 | // Adjust the options array so we can use it to call the standard |
369 | // render function on the grouped data.... |
370 | $defaultOptions = ['renderType' => $options['multiRenderType'] ?? 'Simple'] |
371 | + $options; |
372 | |
373 | // Collect the results: |
374 | $results = []; |
375 | $input = $callback($data, $options, $this->driver); |
376 | foreach (is_array($input) ? $input : [] as $current) { |
377 | $label = $current['label'] ?? ''; |
378 | $values = $current['values'] ?? null; |
379 | $currentOptions = ($current['options'] ?? []) + $defaultOptions; |
380 | $next = $this->render($label, $values, $currentOptions); |
381 | if ($next !== null) { |
382 | $results = array_merge($results, $next); |
383 | } |
384 | } |
385 | return $results; |
386 | } |
387 | |
388 | /** |
389 | * Render using the record view helper. |
390 | * |
391 | * @param mixed $data Data to render |
392 | * @param array $options Rendering options. |
393 | * |
394 | * @return string |
395 | */ |
396 | protected function renderRecordHelper( |
397 | $data, |
398 | array $options |
399 | ) { |
400 | $method = $options['helperMethod'] ?? null; |
401 | $plugin = $this->getView()->plugin('record'); |
402 | if (empty($method) || !is_callable([$plugin, $method])) { |
403 | throw new \Exception('Cannot call "' . $method . '" on helper.'); |
404 | } |
405 | return $plugin($this->driver)->$method($data); |
406 | } |
407 | |
408 | /** |
409 | * Render a record driver template. |
410 | * |
411 | * @param mixed $data Data to render |
412 | * @param array $options Rendering options. |
413 | * |
414 | * @return string |
415 | */ |
416 | protected function renderRecordDriverTemplate( |
417 | $data, |
418 | array $options |
419 | ) { |
420 | if (!isset($options['template'])) { |
421 | throw new \Exception('Template option missing.'); |
422 | } |
423 | $helper = $this->getView()->plugin('record'); |
424 | $context = $options['context'] ?? []; |
425 | $context['driver'] = $this->driver; |
426 | $context['data'] = $data; |
427 | return trim( |
428 | $helper($this->driver)->renderTemplate($options['template'], $context) |
429 | ); |
430 | } |
431 | |
432 | /** |
433 | * Get a link associated with a value, or else return false if link does |
434 | * not apply. |
435 | * |
436 | * @param string $value Value associated with link. |
437 | * @param array $options Rendering options. |
438 | * |
439 | * @return string|bool |
440 | */ |
441 | protected function getLink($value, $options) |
442 | { |
443 | if ($options['recordLink'] ?? false) { |
444 | $helper = $this->getView()->plugin('record'); |
445 | return $helper->getLink($options['recordLink'], $value); |
446 | } |
447 | return false; |
448 | } |
449 | |
450 | /** |
451 | * Render standard and alternative fields together. |
452 | * |
453 | * @param mixed $data Data to render |
454 | * @param array $options Rendering options. |
455 | * |
456 | * @return string |
457 | */ |
458 | protected function renderCombineAlt( |
459 | $data, |
460 | array $options |
461 | ) { |
462 | // Determine the rendering method to use, and bail out if it's illegal: |
463 | $method = empty($options['combineAltRenderType']) |
464 | ? 'renderSimple' : 'render' . $options['combineAltRenderType']; |
465 | if (!is_callable([$this, $method])) { |
466 | return null; |
467 | } |
468 | |
469 | // get standard value |
470 | $stdValue = $this->$method($data, $options); |
471 | |
472 | // get alternative value |
473 | $altDataMethod = $options['altDataMethod'] ?? $options['dataMethod'] . 'AltScript'; |
474 | |
475 | $altOptions = $options; |
476 | $altOptions['dataMethod'] = $altDataMethod; |
477 | $altData = $this->extractData($altOptions); |
478 | |
479 | $altValue = $altData != null ? $this->$method($altData, $altOptions) : null; |
480 | |
481 | // check if both values are not allowed |
482 | if (!$this->allowValue($stdValue, $options) && !$this->allowValue($altValue, $options)) { |
483 | return null; |
484 | } |
485 | |
486 | // render both values |
487 | $helper = $this->getView()->plugin('record'); |
488 | $template = $options['combineAltTemplate'] ?? 'combine-alt'; |
489 | $context = [ |
490 | 'stdValue' => $stdValue, |
491 | 'altValue' => $altValue, |
492 | 'prioritizeAlt' => $options['prioritizeAlt'] ?? false, |
493 | ]; |
494 | return trim( |
495 | $helper($this->driver)->renderTemplate($template, $context) |
496 | ); |
497 | } |
498 | |
499 | /** |
500 | * Simple rendering method. |
501 | * |
502 | * @param mixed $data Data to render |
503 | * @param array $options Rendering options. |
504 | * |
505 | * @return string |
506 | * |
507 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
508 | */ |
509 | protected function renderSimple($data, array $options) |
510 | { |
511 | $view = $this->getView(); |
512 | $escaper = ($options['translate'] ?? false) |
513 | ? $view->plugin('transEsc') : $view->plugin('escapeHtml'); |
514 | $transDomain = $options['translationTextDomain'] ?? ''; |
515 | $separator = $options['separator'] ?? '<br>'; |
516 | $retVal = ''; |
517 | $array = (array)$data; |
518 | $remaining = count($array); |
519 | foreach ($array as $line) { |
520 | $remaining--; |
521 | $text = $options['itemPrefix'] ?? ''; |
522 | $text .= $escaper($transDomain . $line); |
523 | $text .= $options['itemSuffix'] ?? ''; |
524 | $retVal .= ($link = $this->getLink($line, $options)) |
525 | ? '<a href="' . $link . '">' . $text . '</a>' : $text; |
526 | if ($remaining > 0) { |
527 | $retVal .= $separator; |
528 | } |
529 | } |
530 | return ($options['prefix'] ?? '') |
531 | . $retVal |
532 | . ($options['suffix'] ?? ''); |
533 | } |
534 | } |