Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.41% covered (warning)
88.41%
145 / 164
43.75% covered (danger)
43.75%
7 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
RecordDataFormatter
88.41% covered (warning)
88.41%
145 / 164
43.75% covered (danger)
43.75%
7 / 16
75.19
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
 __invoke
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 sortCallback
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 allowValue
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
6
 render
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
8.07
 getData
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
7.27
 getDefaults
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
6.22
 setDefaults
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 addOptions
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 extractData
57.14% covered (warning)
57.14%
8 / 14
0.00% covered (danger)
0.00%
0 / 1
10.86
 renderMulti
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
5.01
 renderRecordHelper
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 renderRecordDriverTemplate
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 getLink
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 renderCombineAlt
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
6.03
 renderSimple
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
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
32namespace VuFind\View\Helper\Root;
33
34use Laminas\View\Helper\AbstractHelper;
35use VuFind\RecordDriver\AbstractBase as RecordDriver;
36
37use function call_user_func;
38use function count;
39use function is_array;
40use 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 */
53class 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}