Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 343
0.00% covered (danger)
0.00%
0 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
BrowseController
0.00% covered (danger)
0.00%
0 / 343
0.00% covered (danger)
0.00%
0 / 23
6642
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 setCurrentAction
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCurrentAction
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getActiveBrowseOptions
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 buildBrowseOptions
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
90
 createViewModel
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 buildBrowseOption
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 homeAction
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 performSearch
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
90
 tagAction
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
72
 lccAction
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 deweyAction
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
20
 performBrowse
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 authorAction
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 topicAction
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 genreAction
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 regionAction
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 eraAction
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getSecondaryList
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
72
 getFacetList
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
42
 quoteValues
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getCategory
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
110
 getAlphabetList
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3/**
4 * Browse Module Controller
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2011.
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  Controller
25 * @author   Chris Hallberg <challber@villanova.edu>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
28 */
29
30namespace VuFind\Controller;
31
32use Laminas\Config\Config;
33use Laminas\ServiceManager\ServiceLocatorInterface;
34use VuFind\Exception\Forbidden as ForbiddenException;
35use VuFind\Tags\TagsService;
36
37use function array_slice;
38use function in_array;
39
40/**
41 * BrowseController Class
42 *
43 * Controls the alphabetical browsing feature
44 *
45 * @category VuFind
46 * @package  Controller
47 * @author   Chris Hallberg <challber@villanova.edu>
48 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
49 * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
50 */
51class BrowseController extends AbstractBase implements
52    \VuFind\I18n\HasSorterInterface
53{
54    use \VuFind\I18n\HasSorterTrait;
55
56    /**
57     * VuFind configuration
58     *
59     * @var \Laminas\Config\Config
60     */
61    protected $config;
62
63    /**
64     * Current browse mode
65     *
66     * @var string
67     */
68    protected $currentAction = null;
69
70    /**
71     * Browse options disabled in configuration
72     *
73     * @var array
74     */
75    protected $disabledFacets;
76
77    /**
78     * Constructor
79     *
80     * @param ServiceLocatorInterface $sm     Service manager
81     * @param Config                  $config VuFind configuration
82     */
83    public function __construct(ServiceLocatorInterface $sm, Config $config)
84    {
85        $this->config = $config;
86
87        $this->disabledFacets = [];
88        foreach ($this->config->Browse as $key => $setting) {
89            if ($setting == false) {
90                $this->disabledFacets[] = $key;
91            }
92        }
93        $this->setSorter($sm->get(\VuFind\I18n\Sorter::class));
94        parent::__construct($sm);
95    }
96
97    /**
98     * Set the name of the current action.
99     *
100     * @param string $name Name of the current action
101     *
102     * @return void
103     */
104    protected function setCurrentAction($name)
105    {
106        $this->currentAction = $name;
107    }
108
109    /**
110     * Get the name of the current action.
111     *
112     * @return string
113     */
114    protected function getCurrentAction()
115    {
116        return $this->currentAction;
117    }
118
119    /**
120     * Determine which browse options to display, and in which order. Returns an
121     * array of browse options in the configured order.
122     *
123     * @return array
124     */
125    protected function getActiveBrowseOptions()
126    {
127        // Get a list of all of the options mentioned in config.ini:
128        $browseConfig = $this->config->Browse->toArray();
129        $configuredOptions = array_keys($browseConfig);
130
131        // This is a list of all available browse options:
132        $allOptions = [
133            'tag', 'dewey', 'lcc', 'author', 'topic', 'genre', 'region', 'era',
134        ];
135
136        // By default, all options except dewey are turned on if omitted from config:
137        $defaultOptions = array_diff($allOptions, ['dewey']);
138
139        // This is a callback function for array_filter, which will filter out any
140        // settings set to false in config.ini:
141        $filter = function ($option) use ($browseConfig) {
142            return (bool)($browseConfig[$option] ?? false);
143        };
144
145        // The active options are a list of configured settings set to true in
146        // config.ini, merged with any default options that were not configured in
147        // config.ini at all:
148        return array_merge(
149            array_filter(array_intersect($configuredOptions, $allOptions), $filter),
150            array_diff($defaultOptions, $configuredOptions)
151        );
152    }
153
154    /**
155     * Given a list of active options, format them into details for the view.
156     *
157     * @return array
158     */
159    protected function buildBrowseOptions()
160    {
161        // Initialize the array of top-level browse options.
162        $browseOptions = [];
163
164        $activeOptions = $this->getActiveBrowseOptions();
165        foreach ($activeOptions as $option) {
166            switch ($option) {
167                case 'dewey':
168                    $deweyLabel = in_array('lcc', $activeOptions)
169                        ? 'browse_dewey' : 'Call Number';
170                    $browseOptions[] = $this
171                        ->buildBrowseOption('Dewey', $deweyLabel);
172                    break;
173                case 'lcc':
174                    $lccLabel = in_array('dewey', $activeOptions)
175                        ? 'browse_lcc' : 'Call Number';
176                    $browseOptions[] = $this->buildBrowseOption('LCC', $lccLabel);
177                    break;
178                case 'tag':
179                    if ($this->tagsEnabled()) {
180                        $browseOptions[] = $this->buildBrowseOption('Tag', 'Tag');
181                    }
182                    break;
183                default:
184                    $current = ucwords($option);
185                    $browseOptions[] = $this->buildBrowseOption($current, $current);
186                    break;
187            }
188        }
189
190        return $browseOptions;
191    }
192
193    /**
194     * Create a new ViewModel.
195     *
196     * @param array $params Parameters to pass to ViewModel constructor.
197     *
198     * @return \Laminas\View\Model\ViewModel
199     */
200    protected function createViewModel($params = null)
201    {
202        $view = parent::createViewModel($params);
203
204        // Set the current action.
205        $currentAction = $this->getCurrentAction();
206        if (!empty($currentAction)) {
207            $view->currentAction = $currentAction;
208        }
209
210        // CARRY
211        if ($findby = $this->params()->fromQuery('findby')) {
212            $view->findby = $findby;
213        }
214        if ($query = $this->params()->fromQuery('query')) {
215            $view->query = $query;
216        }
217        if ($category = $this->params()->fromQuery('category')) {
218            $view->category = $category;
219        }
220        $view->browseOptions = $this->buildBrowseOptions();
221
222        return $view;
223    }
224
225    /**
226     * Build an array containing options describing a top-level Browse option.
227     *
228     * @param string $action      The name of the Action for this option
229     * @param string $description A description of this Browse option
230     *
231     * @return array              The Browse option array
232     */
233    protected function buildBrowseOption($action, $description)
234    {
235        return ['action' => $action, 'description' => $description];
236    }
237
238    /**
239     * Gathers data for the view of the AlphaBrowser and does some initialization
240     *
241     * @return \Laminas\View\Model\ViewModel
242     */
243    public function homeAction()
244    {
245        $this->setCurrentAction('Home');
246        return $this->createViewModel();
247    }
248
249    /**
250     * Perform the search
251     *
252     * @param \Laminas\View\Model\ViewModel $view View model to modify
253     *
254     * @return \Laminas\View\Model\ViewModel
255     */
256    protected function performSearch($view)
257    {
258        // Remove disabled facets
259        $facets = $view->categoryList;
260        foreach ($this->disabledFacets as $facet) {
261            unset($facets[$facet]);
262        }
263        $view->categoryList = $facets;
264
265        // SEARCH (Tag does its own search)
266        if (
267            $this->params()->fromQuery('query')
268            && $this->getCurrentAction() != 'Tag'
269        ) {
270            $results = $this->getFacetList(
271                $this->params()->fromQuery('facet_field'),
272                $this->params()->fromQuery('query_field'),
273                'count',
274                $this->params()->fromQuery('query')
275            );
276            $resultList = [];
277            foreach ($results as $result) {
278                $resultList[] = [
279                    'displayText' => $result['displayText'],
280                    'value' => $result['value'],
281                    'count' => $result['count'],
282                ];
283            }
284            // Don't make a second filter if it would be the same facet
285            $view->paramTitle
286                = ($this->params()->fromQuery('query_field') != $this->getCategory())
287                ? 'filter[]=' . $this->params()->fromQuery('query_field') . ':'
288                    . urlencode($this->params()->fromQuery('query')) . '&'
289                : '';
290            switch ($this->getCurrentAction()) {
291                case 'LCC':
292                    $view->paramTitle .= 'filter[]=callnumber-subject:';
293                    break;
294                case 'Dewey':
295                    $view->paramTitle .= 'filter[]=dewey-ones:';
296                    break;
297                default:
298                    $view->paramTitle .= 'filter[]=' . $this->getCategory() . ':';
299            }
300            $view->paramTitle = str_replace(
301                '+AND+',
302                '&filter[]=',
303                $view->paramTitle
304            );
305            $view->resultList = $resultList;
306        }
307
308        $view->setTemplate('browse/home');
309        return $view;
310    }
311
312    /**
313     * Browse tags
314     *
315     * @return \Laminas\View\Model\ViewModel
316     */
317    public function tagAction()
318    {
319        if (!$this->tagsEnabled()) {
320            throw new ForbiddenException('Tags disabled.');
321        }
322
323        $this->setCurrentAction('Tag');
324        $view = $this->createViewModel();
325
326        $view->categoryList = [
327            'alphabetical' => 'By Alphabetical',
328            'popularity'   => 'By Popularity',
329            'recent'       => 'By Recent',
330        ];
331
332        if ($this->params()->fromQuery('findby')) {
333            $params = $this->getRequest()->getQuery()->toArray();
334            $tagsService = $this->getService(TagsService::class);
335            // Special case -- display alphabet selection if necessary:
336            if ($params['findby'] == 'alphabetical') {
337                $legalLetters = $this->getAlphabetList();
338                $view->secondaryList = $legalLetters;
339                // Only display tag list when a valid letter is selected:
340                if (isset($params['query'])) {
341                    // Note -- this does not need to be escaped because
342                    // $params['query'] has already been validated against
343                    // the getAlphabetList() method below!
344                    $tags = $tagsService->getNonListTagsFuzzilyMatchingString($params['query']);
345                    $tagList = [];
346                    foreach ($tags as $tag) {
347                        if ($tag['cnt'] > 0) {
348                            $tagList[] = [
349                                'displayText' => $tag['tag'],
350                                'value' => $tag['tag'],
351                                'count' => $tag['cnt'],
352                            ];
353                        }
354                    }
355                    $view->resultList = array_slice(
356                        $tagList,
357                        0,
358                        $this->config->Browse->result_limit
359                    );
360                }
361            } else {
362                // Default case: always display tag list for non-alphabetical modes:
363                $tagList = $tagsService->getTagBrowseList(
364                    $params['findby'],
365                    (int)($this->config->Browse->result_limit ?? 100)
366                );
367                $resultList = [];
368                foreach ($tagList as $i => $tag) {
369                    $resultList[$i] = [
370                        'displayText' => $tag['tag'],
371                        'value' => $tag['tag'],
372                        'count'    => $tag['cnt'],
373                    ];
374                }
375                $view->resultList = $resultList;
376            }
377            $view->paramTitle = 'lookfor=';
378            $view->searchParams = [];
379        }
380
381        return $this->performSearch($view);
382    }
383
384    /**
385     * Browse LCC
386     *
387     * @return \Laminas\View\Model\ViewModel
388     */
389    public function lccAction()
390    {
391        $this->setCurrentAction('LCC');
392        $view = $this->createViewModel();
393        [$view->filter, $view->secondaryList] = $this->getSecondaryList('lcc');
394        $view->secondaryParams = [
395            'query_field' => 'callnumber-first',
396            'facet_field' => 'callnumber-subject',
397        ];
398        $view->searchParams = ['sort' => 'callnumber-sort'];
399        return $this->performSearch($view);
400    }
401
402    /**
403     * Browse Dewey
404     *
405     * @return \Laminas\View\Model\ViewModel
406     */
407    public function deweyAction()
408    {
409        $this->setCurrentAction('Dewey');
410        $view = $this->createViewModel();
411        [$view->filter, $hundredsList] = $this->getSecondaryList('dewey');
412        $categoryList = [];
413        foreach ($hundredsList as $dewey) {
414            $categoryList[$dewey['value']] = [
415                'text' => $dewey['displayText'],
416                'count' => $dewey['count'],
417            ];
418        }
419        $view->categoryList = $categoryList;
420        $view->dewey_flag = 1;
421        if ($this->params()->fromQuery('findby')) {
422            $secondaryList = $this->quoteValues(
423                $this->getFacetList(
424                    'dewey-tens',
425                    'dewey-hundreds',
426                    'count',
427                    $this->params()->fromQuery('findby')
428                )
429            );
430            foreach (array_keys($secondaryList) as $index) {
431                $secondaryList[$index]['value'] .=
432                    ' AND dewey-hundreds:'
433                    . $this->params()->fromQuery('findby');
434            }
435            $view->secondaryList = $secondaryList;
436            $view->secondaryParams = [
437                'query_field' => 'dewey-tens',
438                'facet_field' => 'dewey-ones',
439            ];
440            $view->searchParams = ['sort' => 'dewey-sort'];
441        }
442        return $this->performSearch($view);
443    }
444
445    /**
446     * Generic action function that handles all the common parts of the below actions
447     *
448     * @param string $currentAction name of the current action. profound stuff.
449     * @param array  $categoryList  category options
450     * @param string $facetPrefix   if this is true and we're looking
451     * alphabetically, add a facet_prefix to the URL
452     *
453     * @return \Laminas\View\Model\ViewModel
454     */
455    protected function performBrowse($currentAction, $categoryList, $facetPrefix)
456    {
457        $this->setCurrentAction($currentAction);
458        $view = $this->createViewModel();
459        $view->categoryList = $categoryList;
460
461        $findby = $this->params()->fromQuery('findby');
462        if ($findby) {
463            $view->secondaryParams = [
464                'query_field' => $this->getCategory($findby),
465                'facet_field' => $this->getCategory($currentAction),
466            ];
467            $view->facetPrefix = $facetPrefix && $findby == 'alphabetical';
468            [$view->filter, $view->secondaryList]
469                = $this->getSecondaryList($findby);
470        }
471
472        return $this->performSearch($view);
473    }
474
475    /**
476     * Browse Author
477     *
478     * @return \Laminas\View\Model\ViewModel
479     */
480    public function authorAction()
481    {
482        $categoryList = [
483            'alphabetical' => 'By Alphabetical',
484            'lcc'          => 'By Call Number',
485            'topic'        => 'By Topic',
486            'genre'        => 'By Genre',
487            'region'       => 'By Region',
488            'era'          => 'By Era',
489        ];
490
491        return $this->performBrowse('Author', $categoryList, true);
492    }
493
494    /**
495     * Browse Topic
496     *
497     * @return \Laminas\View\Model\ViewModel
498     */
499    public function topicAction()
500    {
501        $categoryList = [
502            'alphabetical' => 'By Alphabetical',
503            'genre'        => 'By Genre',
504            'region'       => 'By Region',
505            'era'          => 'By Era',
506        ];
507
508        return $this->performBrowse('Topic', $categoryList, true);
509    }
510
511    /**
512     * Browse Genre
513     *
514     * @return \Laminas\View\Model\ViewModel
515     */
516    public function genreAction()
517    {
518        $categoryList = [
519            'alphabetical' => 'By Alphabetical',
520            'topic'        => 'By Topic',
521            'region'       => 'By Region',
522            'era'          => 'By Era',
523        ];
524
525        return $this->performBrowse('Genre', $categoryList, true);
526    }
527
528    /**
529     * Browse Region
530     *
531     * @return \Laminas\View\Model\ViewModel
532     */
533    public function regionAction()
534    {
535        $categoryList = [
536            'alphabetical' => 'By Alphabetical',
537            'topic'        => 'By Topic',
538            'genre'        => 'By Genre',
539            'era'          => 'By Era',
540        ];
541
542        return $this->performBrowse('Region', $categoryList, true);
543    }
544
545    /**
546     * Browse Era
547     *
548     * @return \Laminas\View\Model\ViewModel
549     */
550    public function eraAction()
551    {
552        $categoryList = [
553            'alphabetical' => 'By Alphabetical',
554            'topic'        => 'By Topic',
555            'genre'        => 'By Genre',
556            'region'       => 'By Region',
557        ];
558
559        return $this->performBrowse('Era', $categoryList, true);
560    }
561
562    /**
563     * Get array with two values: a filter name and a secondary list based on facets
564     *
565     * @param string $facet the facet we need the contents of
566     *
567     * @return array
568     */
569    protected function getSecondaryList($facet)
570    {
571        $category = $this->getCategory();
572        switch ($facet) {
573            case 'alphabetical':
574                return ['', $this->getAlphabetList()];
575            case 'dewey':
576                return [
577                        'dewey-tens', $this->quoteValues(
578                            $this->getFacetList('dewey-hundreds', $category, 'index')
579                        ),
580                    ];
581            case 'lcc':
582                return [
583                        'callnumber-first', $this->quoteValues(
584                            $this->getFacetList(
585                                'callnumber-first',
586                                $category,
587                                'index'
588                            )
589                        ),
590                    ];
591            case 'topic':
592                return [
593                        'topic_facet', $this->quoteValues(
594                            $this->getFacetList('topic_facet', $category)
595                        ),
596                    ];
597            case 'genre':
598                return [
599                        'genre_facet', $this->quoteValues(
600                            $this->getFacetList('genre_facet', $category)
601                        ),
602                    ];
603            case 'region':
604                return [
605                        'geographic_facet', $this->quoteValues(
606                            $this->getFacetList('geographic_facet', $category)
607                        ),
608                    ];
609            case 'era':
610                return [
611                        'era_facet', $this->quoteValues(
612                            $this->getFacetList('era_facet', $category)
613                        ),
614                    ];
615        }
616        throw new \Exception('Unexpected value: ' . $facet);
617    }
618
619    /**
620     * Get a list of items from a facet.
621     *
622     * @param string $facet    which facet we're searching in
623     * @param string $category which subfacet the search applies to
624     * @param string $sort     how are we ranking these? || 'index'
625     * @param string $query    is there a specific query? No = wildcard
626     *
627     * @return array           Array indexed by value with text of displayText and
628     * count
629     */
630    protected function getFacetList(
631        $facet,
632        $category = null,
633        $sort = 'count',
634        $query = '[* TO *]'
635    ) {
636        $results = $this->getService(\VuFind\Search\Results\PluginManager::class)->get('Solr');
637        $params = $results->getParams();
638        $params->addFacet($facet);
639        if ($category != null) {
640            $query = $category . ':' . $query;
641        } else {
642            $query = $facet . ':' . $query;
643        }
644        $params->setOverrideQuery($query);
645        $params->getOptions()->disableHighlighting();
646        $params->getOptions()->spellcheckEnabled(false);
647        // Get limit from config
648        $params->setFacetLimit($this->config->Browse->result_limit);
649        $params->setLimit(0);
650        // Facet prefix
651        if ($this->params()->fromQuery('facet_prefix')) {
652            $params->setFacetPrefix($this->params()->fromQuery('facet_prefix'));
653        }
654        $params->setFacetSort($sort);
655        $result = $results->getFacetList();
656        if (isset($result[$facet])) {
657            // Sort facets alphabetically if configured to do so:
658            if (
659                isset($this->config->Browse->alphabetical_order)
660                && $this->config->Browse->alphabetical_order
661            ) {
662                $callback = function ($a, $b) {
663                    return $this->getSorter()->compare(
664                        $a['displayText'],
665                        $b['displayText']
666                    );
667                };
668                usort($result[$facet]['list'], $callback);
669            }
670            return $result[$facet]['list'];
671        } else {
672            return [];
673        }
674    }
675
676    /**
677     * Helper class that adds quotes around the values of an array
678     *
679     * @param array $array Two-dimensional array where each entry has a value param
680     *
681     * @return array       Array indexed by value with text of displayText and count
682     */
683    protected function quoteValues($array)
684    {
685        foreach ($array as $i => $result) {
686            $result['value'] = '"' . $result['value'] . '"';
687            $array[$i] = $result;
688        }
689        return $array;
690    }
691
692    /**
693     * Get the facet search term for an action
694     *
695     * @param string $action action to be translated
696     *
697     * @return string
698     */
699    protected function getCategory($action = null)
700    {
701        if ($action == null) {
702            $action = $this->getCurrentAction();
703        }
704        switch (strtolower($action)) {
705            case 'alphabetical':
706                return $this->getCategory();
707            case 'dewey':
708                return 'dewey-hundreds';
709            case 'lcc':
710                return 'callnumber-first';
711            case 'author':
712                return 'author_facet';
713            case 'topic':
714                return 'topic_facet';
715            case 'genre':
716                return 'genre_facet';
717            case 'region':
718                return 'geographic_facet';
719            case 'era':
720                return 'era_facet';
721        }
722        return $action;
723    }
724
725    /**
726     * Get a list of letters to display in alphabetical mode.
727     *
728     * @return array
729     */
730    protected function getAlphabetList()
731    {
732        // Get base alphabet:
733        $chars = $this->config->Browse->alphabet_letters
734            ?? 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
735
736        // Put numbers in the front for Era since years are important:
737        if ($this->getCurrentAction() == 'Era') {
738            $chars = '0123456789' . $chars;
739        } else {
740            $chars .= '0123456789';
741        }
742
743        // ALPHABET TO ['value','displayText']
744        // (value has asterisk appended for Solr, but is unmodified for tags)
745        $action = $this->getCurrentAction();
746        $callback = function ($letter) use ($action) {
747            // Tag is a special case because it is database-backed; for everything
748            // else, use a Solr query that will allow case-insensitive lookups.
749            $value = ($action == 'Tag')
750                ? $letter
751                : '(' . strtoupper($letter) . '* OR ' . strtolower($letter) . '*)';
752            return ['value' => $value, 'displayText' => $letter];
753        };
754        preg_match_all('/(.)/u', $chars, $matches);
755        return array_map($callback, $matches[1]);
756    }
757}