Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.29% covered (success)
94.29%
165 / 175
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
HierarchicalFacetHelper
94.29% covered (success)
94.29%
165 / 175
60.00% covered (warning)
60.00%
6 / 10
67.84
0.00% covered (danger)
0.00%
0 / 1
 setViewRenderer
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 sortFacetList
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
14
 buildFacetArray
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 flattenFacetHierarchy
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 formatDisplayText
58.82% covered (warning)
58.82%
10 / 17
0.00% covered (danger)
0.00%
0 / 1
10.42
 getFilterStringParts
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 isDeepestFacetLevel
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 createFacetItem
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
6
 updateAppliedChildrenStatus
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 filterFacets
97.37% covered (success)
97.37%
37 / 38
0.00% covered (danger)
0.00%
0 / 1
16
1<?php
2
3/**
4 * Facet Helper
5 *
6 * PHP version 8
7 *
8 * Copyright (C) The National Library of Finland 2014.
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  Search
25 * @author   Ere Maijala <ere.maijala@helsinki.fi>
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 Main Site
29 */
30
31namespace VuFind\Search\Solr;
32
33use Laminas\View\Renderer\RendererInterface;
34use VuFind\I18n\HasSorterInterface;
35use VuFind\I18n\HasSorterTrait;
36use VuFind\I18n\TranslatableString;
37use VuFind\I18n\Translator\TranslatorAwareInterface;
38use VuFind\I18n\Translator\TranslatorAwareTrait;
39use VuFind\Search\Base\HierarchicalFacetHelperInterface;
40use VuFind\Search\Base\Options;
41use VuFind\Search\UrlQueryHelper;
42
43use function array_slice;
44use function count;
45use function is_string;
46use function strlen;
47
48/**
49 * Functions for manipulating facets
50 *
51 * @category VuFind
52 * @package  Search
53 * @author   Ere Maijala <ere.maijala@helsinki.fi>
54 * @author   Juha Luoma <juha.luoma@helsinki.fi>
55 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
56 * @link     https://vufind.org Main Site
57 */
58class HierarchicalFacetHelper implements
59    HierarchicalFacetHelperInterface,
60    TranslatorAwareInterface,
61    HasSorterInterface
62{
63    use TranslatorAwareTrait;
64    use HasSorterTrait;
65
66    /**
67     * View renderer
68     *
69     * @var RendererInterface
70     */
71    protected $viewRenderer = null;
72
73    /**
74     * Set view renderer
75     *
76     * @param RendererInterface $renderer View renderer
77     *
78     * @return void
79     */
80    public function setViewRenderer(RendererInterface $renderer): void
81    {
82        $this->viewRenderer = $renderer;
83    }
84
85    /**
86     * Helper method for building hierarchical facets:
87     * Sort a facet list according to the given sort order
88     *
89     * @param array          $facetList Facet list returned from Solr
90     * @param boolean|string $order     Sort order:
91     * - true|top  sort top level alphabetically and the rest by count
92     * - false|all sort all levels alphabetically
93     * - count     sort all levels by count
94     *
95     * @return void
96     */
97    public function sortFacetList(&$facetList, $order = null)
98    {
99        // We need a boolean flag indicating whether or not to sort only the top
100        // level of the hierarchy. If we received a string configuration option,
101        // we should set the flag accordingly (boolean values of $order are
102        // supported for backward compatibility).
103        $topLevel = $order ?? 'count';
104        if (is_string($topLevel)) {
105            switch (strtolower(trim($topLevel))) {
106                case 'top':
107                    $topLevel = true;
108                    break;
109                case 'all':
110                    $topLevel = false;
111                    break;
112                case '':
113                case 'count':
114                    // At present, we assume the incoming list is already sorted by
115                    // count, so no further action is needed. If in future we need
116                    // to support re-sorting an arbitrary list, rather than simply
117                    // operating on raw Solr values, we may need to implement logic.
118                    return;
119            }
120        }
121
122        // Parse level from each facet value so that the sort function
123        // can run faster
124        foreach ($facetList as &$facetItem) {
125            [$facetItem['level']] = explode('/', $facetItem['value'], 2);
126            if (!is_numeric($facetItem['level'])) {
127                $facetItem['level'] = 0;
128            }
129        }
130        // Avoid problems having the reference set further below
131        unset($facetItem);
132        $sortFunc = function ($a, $b) use ($topLevel) {
133            if ($a['level'] == $b['level'] && (!$topLevel || $a['level'] == 0)) {
134                $aText = $a['displayText'] == $a['value']
135                    ? $this->formatDisplayText($a['displayText'])
136                    : $a['displayText'];
137                $bText = $b['displayText'] == $b['value']
138                    ? $this->formatDisplayText($b['displayText'])
139                    : $b['displayText'];
140                return $this->getSorter()->compare($aText, $bText);
141            }
142            return $a['level'] == $b['level']
143                ? $b['count'] - $a['count']
144                : $a['level'] - $b['level'];
145        };
146        usort($facetList, $sortFunc);
147    }
148
149    /**
150     * Helper method for building hierarchical facets:
151     * Convert facet list to a hierarchical array
152     *
153     * @param string    $facet     Facet name
154     * @param array     $facetList Facet list
155     * @param UrlHelper $urlHelper Query URL helper for building facet URLs
156     * @param bool      $escape    Whether to escape URLs
157     *
158     * @return array Facet hierarchy
159     *
160     * @see http://blog.tekerson.com/2009/03/03/
161     * converting-a-flat-array-with-parent-ids-to-a-nested-tree/
162     * Based on this example
163     */
164    public function buildFacetArray(
165        $facet,
166        $facetList,
167        $urlHelper = false,
168        $escape = true
169    ) {
170        // Create a keyed (for conversion to hierarchical) array of facet data
171        $keyedList = [];
172        foreach ($facetList as $item) {
173            $keyedList[$item['value']] = $this->createFacetItem(
174                $facet,
175                $item,
176                $urlHelper,
177                $escape
178            );
179        }
180
181        // Convert the keyed array to a hierarchical array
182        $result = [];
183        foreach ($keyedList as &$item) {
184            if ($item['level'] > 0) {
185                $keyedList[$item['parent']]['children'][] = &$item;
186            } else {
187                $result[] = &$item;
188            }
189        }
190
191        // Update information on whether items have applied children
192        $this->updateAppliedChildrenStatus($result);
193
194        return $result;
195    }
196
197    /**
198     * Flatten a hierarchical facet list to a simple array
199     *
200     * @param array $facetList Facet list
201     *
202     * @return array Simple array of facets
203     */
204    public function flattenFacetHierarchy($facetList)
205    {
206        $results = [];
207        foreach ($facetList as $facetItem) {
208            $children = !empty($facetItem['children'])
209                ? $facetItem['children']
210                : [];
211            unset($facetItem['children']);
212            $results[] = $facetItem;
213            if ($children) {
214                $results = array_merge(
215                    $results,
216                    $this->flattenFacetHierarchy($children)
217                );
218            }
219        }
220        return $results;
221    }
222
223    /**
224     * Format a facet display text for displaying
225     *
226     * @param string       $displayText Display text
227     * @param bool         $allLevels   Whether to display all levels or only the
228     * current one
229     * @param string       $separator   Separator string displayed between levels
230     * @param string|false $domain      Translation domain for default translations
231     * of a multilevel string or false to omit translation
232     *
233     * @return TranslatableString Formatted text
234     */
235    public function formatDisplayText(
236        $displayText,
237        $allLevels = false,
238        $separator = '/',
239        $domain = false
240    ) {
241        $originalText = $displayText;
242        $parts = explode('/', $displayText);
243        if (count($parts) > 1 && is_numeric($parts[0])) {
244            if (!$allLevels && isset($parts[$parts[0] + 1])) {
245                $displayText = $parts[$parts[0] + 1];
246            } else {
247                array_shift($parts);
248                array_pop($parts);
249
250                if (false !== $domain) {
251                    $translatedParts = [];
252                    foreach ($parts as $part) {
253                        $translatedParts[] = $this->translate([$domain, $part]);
254                    }
255                    $displayText = new TranslatableString(
256                        implode($separator, $parts),
257                        implode($separator, $translatedParts)
258                    );
259                } else {
260                    $displayText = implode($separator, $parts);
261                }
262            }
263        }
264        return new TranslatableString($originalText, $displayText);
265    }
266
267    /**
268     * Format a filter string in parts suitable for displaying or translation
269     *
270     * @param string $filter Filter value
271     *
272     * @return array
273     */
274    public function getFilterStringParts($filter)
275    {
276        $parts = explode('/', $filter);
277        if (count($parts) <= 1 || !is_numeric($parts[0])) {
278            return [new TranslatableString($filter, $filter)];
279        }
280        $result = [];
281        for ($level = 0; $level <= $parts[0]; $level++) {
282            $str = $level . '/' . implode('/', array_slice($parts, 1, $level + 1))
283                . '/';
284            $result[] = new TranslatableString($str, $parts[$level + 1]);
285        }
286        return $result;
287    }
288
289    /**
290     * Check if the given value is the deepest level in the facet list.
291     *
292     * Takes into account lists with multiple top levels.
293     *
294     * @param array  $facetList Facet list
295     * @param string $value     Facet value
296     *
297     * @return bool
298     */
299    public function isDeepestFacetLevel($facetList, $value)
300    {
301        $parts = explode('/', $value);
302        $level = array_shift($parts);
303        if (!is_numeric($level)) {
304            // Not a properly formatted hierarchical facet value
305            return true;
306        }
307        $path = implode('/', array_slice($parts, 0, $level + 1));
308        foreach ($facetList as $current) {
309            $parts = explode('/', $current);
310            $currentLevel = array_shift($parts);
311            if (is_numeric($currentLevel) && $currentLevel > $level) {
312                // Check if parent is same
313                if ($path === implode('/', array_slice($parts, 0, $level + 1))) {
314                    return false;
315                }
316            }
317        }
318        return true;
319    }
320
321    /**
322     * Create an item for the hierarchical facet array
323     *
324     * @param string         $facet     Facet name
325     * @param array          $item      Facet item received from Solr
326     * @param UrlQueryHelper $urlHelper UrlQueryHelper for creating facet URLs
327     * @param bool           $escape    Whether to escape URLs
328     *
329     * @return array Facet item
330     */
331    protected function createFacetItem($facet, $item, $urlHelper, $escape = true)
332    {
333        $href = '';
334        $exclude = '';
335        // Build URLs only if we were given an URL helper
336        if ($urlHelper !== false) {
337            if ($item['isApplied']) {
338                $href = $urlHelper->removeFacet(
339                    $facet,
340                    $item['value'],
341                    $item['operator']
342                )->getParams($escape);
343            } else {
344                $href = $urlHelper->addFacet(
345                    $facet,
346                    $item['value'],
347                    $item['operator']
348                )->getParams($escape);
349            }
350            $exclude = $urlHelper->addFacet($facet, $item['value'], 'NOT')
351                ->getParams($escape);
352        }
353
354        $displayText = $item['displayText'];
355        if ($displayText == $item['value']) {
356            // Only show the current level part
357            $displayText = $this->formatDisplayText($displayText)
358                ->getDisplayString();
359        }
360
361        $parts = explode('/', $item['value'], 2);
362        $level = $parts[0];
363        $value = $parts[1] ?? $item['value'];
364        if (!is_numeric($level)) {
365            $level = 0;
366        }
367        $parent = null;
368        if ($level > 0) {
369            $parent = ($level - 1) . '/' . implode(
370                '/',
371                array_slice(
372                    explode('/', $value),
373                    0,
374                    $level
375                )
376            ) . '/';
377        }
378
379        $item['level'] = $level;
380        $item['parent'] = $parent;
381        $item['displayText'] = $displayText;
382        // hasAppliedChildren is updated in updateAppliedChildrenStatus
383        $item['hasAppliedChildren'] = false;
384        $item['href'] = $href;
385        $item['exclude'] = $exclude;
386        $item['children'] = [];
387
388        return $item;
389    }
390
391    /**
392     * Update 'opened' of all facet items
393     *
394     * @param array $list Facet list
395     *
396     * @return bool Whether any items are applied (for recursive calls)
397     */
398    protected function updateAppliedChildrenStatus($list)
399    {
400        $result = false;
401        foreach ($list as &$item) {
402            $item['hasAppliedChildren'] = !empty($item['children'])
403                && $this->updateAppliedChildrenStatus($item['children']);
404            if ($item['isApplied'] || $item['hasAppliedChildren']) {
405                $result = true;
406            }
407        }
408        return $result;
409    }
410
411    /**
412     * Filter hierarchical facets
413     *
414     * @param string  $name    Facet name
415     * @param array   $facets  Facet list
416     * @param Options $options Options
417     *
418     * @return array
419     */
420    public function filterFacets($name, $facets, $options): array
421    {
422        $filters = $options->getHierarchicalFacetFilters($name);
423        $excludeFilters = $options->getHierarchicalExcludeFilters($name);
424
425        if (!$filters && !$excludeFilters) {
426            return $facets;
427        }
428
429        if ($filters) {
430            foreach ($facets as $key => &$facet) {
431                $value = $facet['value'];
432                [$level] = explode('/', $value);
433                $match = false;
434                $levelSpecified = false;
435                foreach ($filters as $filterItem) {
436                    [$filterLevel] = explode('/', $filterItem);
437                    if ($level === $filterLevel) {
438                        $levelSpecified = true;
439                    }
440                    if (strncmp($value, $filterItem, strlen($filterItem)) == 0) {
441                        $match = true;
442                    }
443                }
444                if (!$match && $levelSpecified) {
445                    unset($facets[$key]);
446                } elseif (!empty($facet['children'])) {
447                    $facet['children'] = $this->filterFacets(
448                        $name,
449                        $facet['children'],
450                        $options
451                    );
452                }
453            }
454        }
455
456        if ($excludeFilters) {
457            foreach ($facets as $key => &$facet) {
458                $value = $facet['value'];
459                foreach ($excludeFilters as $filterItem) {
460                    if (strncmp($value, $filterItem, strlen($filterItem)) == 0) {
461                        unset($facets[$key]);
462                        continue 2;
463                    }
464                }
465                if (!empty($facet['children'])) {
466                    $facet['children'] = $this->filterFacets(
467                        $name,
468                        $facet['children'],
469                        $options
470                    );
471                }
472            }
473        }
474
475        return array_values($facets);
476    }
477}