Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
94.29% |
165 / 175 |
|
60.00% |
6 / 10 |
CRAP | |
0.00% |
0 / 1 |
HierarchicalFacetHelper | |
94.29% |
165 / 175 |
|
60.00% |
6 / 10 |
67.84 | |
0.00% |
0 / 1 |
setViewRenderer | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
sortFacetList | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
14 | |||
buildFacetArray | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
4 | |||
flattenFacetHierarchy | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
4 | |||
formatDisplayText | |
58.82% |
10 / 17 |
|
0.00% |
0 / 1 |
10.42 | |||
getFilterStringParts | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
isDeepestFacetLevel | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
6 | |||
createFacetItem | |
100.00% |
43 / 43 |
|
100.00% |
1 / 1 |
6 | |||
updateAppliedChildrenStatus | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
5 | |||
filterFacets | |
97.37% |
37 / 38 |
|
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 | |
31 | namespace VuFind\Search\Solr; |
32 | |
33 | use Laminas\View\Renderer\RendererInterface; |
34 | use VuFind\I18n\HasSorterInterface; |
35 | use VuFind\I18n\HasSorterTrait; |
36 | use VuFind\I18n\TranslatableString; |
37 | use VuFind\I18n\Translator\TranslatorAwareInterface; |
38 | use VuFind\I18n\Translator\TranslatorAwareTrait; |
39 | use VuFind\Search\Base\HierarchicalFacetHelperInterface; |
40 | use VuFind\Search\Base\Options; |
41 | use VuFind\Search\UrlQueryHelper; |
42 | |
43 | use function array_slice; |
44 | use function count; |
45 | use function is_string; |
46 | use 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 | */ |
58 | class 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 | } |