PageSourceSearch

https://www.oelsnitz-erzgeb.com/_assets/75c37665310f763c4b5c5d9189c40bbf/Js/controllers/filter_controller.js

js oelsnitz-erzgeb.com collected 2026-10-02 18:39:05 UTC 38,190 bytes, 1,108 lines download raw bytes

1import { Controller } from 'stimulus';
2
3export default class FilterController extends Controller {
4    static targets = [
5        'form',
6        'fieldset',
7        'sorting',
8        'list',
9        'listEmpty',
10        'type',
11        'pid',
12        'sorting',
13        'sortingSelect',
14        'facet',
15        'loadMoreInformation',
16        'loadMoreContainer',
17        'searchfield',
18        'suggestionlist',
19        'activeFilterQuantity',
20        'overlay',
21        'atoz',
22        'searchType',
23        'overlayToggle',
24        'closeOverlay',
25        'additionalSolrQ',
26        'additionalFacetTypes',
27    ]
28
29    params = {}
30    fullTextSearch = ''
31    type = ''
32    page = 1
33    sort = ''
34    dateRange = ''
35    dateRangeRaw = ''
36    suggestionlistOnLoad = false
37    lastLetter = ''
38    hasAtoz = ''
39    searchType = ''
40    searchInput = ''
41    lastChangedType= ''
42    filterNumber = 0
43
44    functionsAfterListFetch = {}
45
46    filterDataObject = {}
47
48    static values = {
49        showOverlay: Boolean,
50    }
51
52    /**
53     * will be called once on initialization
54     */
55    connect() {
56        super.connect()
57        this.listEmptyTarget.style.display = 'none'
58        this.type = this.hasTypeTarget ? this.typeTarget.value : ''
59        this.sort = this.hasSortingSelectTarget
60            ? this.sortingSelectTarget.value
61            : ''
62        this.hasAtoz = this.hasAtozTarget ? '1' : ''
63        this.searchType = this.hasSearchTypeTarget
64            ? this.searchTypeTarget.value
65            : 'all'
66
67        if (document.querySelector('[data-type-from="start_dateS"]')) {
68            this.dateRange = this.setDefaultTimeRange()
69        }  else {
70            this.dateRange = ''
71        }
72        this.updateParams()
73        this.fetchListItems()
74
75        if (this.hasSuggestionlistTarget) {
76            this.searchInput = document.querySelector('[aria-controls="' + this.suggestionlistTarget.getAttribute('id') + '"]');
77        }
78
79        window.FnnSolrFilterController = this;
80    }
81
82    /**
83     * handles type changes (facets)
84     *
85     * @param event
86     */
87    onFacetChange(event) {
88        for (let i in this.facetTargets) {
89            this.facetTargets[i].classList.remove('active')
90        }
91
92        if (event.target.classList.contains('active')) {
93            event.target.classList.remove('active')
94            this.type = ''
95        } else {
96            event.target.classList.add('active')
97            this.type = event.target.value
98        }
99
100        this.page = 1
101        this.fetchListItems()
102    }
103
104    /**
105     * handles change events on input change
106     *
107     * @param event
108     */
109    onChange(event) {
110        let name = event.target.name
111        let value = event.target.value
112        let attribute = event.target.hasAttribute('data-type')
113            ? event.target.getAttribute('data-type')
114            : ''
115        let conjunction = event.target.hasAttribute('data-conjunction')
116            ? event.target.getAttribute('data-conjunction')
117            : ''
118        let isFacet = event.target.hasAttribute('data-is-facet')
119            ? (event.target.getAttribute('data-is-facet') === '1' ? true : false)
120            : false
121        this.params = {
122            ...this.params,
123            [name]: {
124                attribute: attribute,
125                conjunction: conjunction,
126                isFacet: isFacet,
127                values: this.params[name]?.values
128                    ? this.params[name].values
129                    : [],
130            },
131        }
132
133        switch (event.target.type) {
134            case 'checkbox':
135                if (event.target.checked) {
136                    this.params[name].values = [
137                        ...this.params[name].values,
138                        value,
139                    ]
140                } else {
141                    let index = this.params[name].values.indexOf(value)
142                    if (index > -1) this.params[name].values.splice(index, 1)
143                    this.lastLetter = ''
144                }
145                this.lastChangedType = event.target.dataset.type
146                break
147            case 'button':
148                if (event.target.classList.contains('active')) {
149                    event.target.classList.remove('active')
150                    delete this.params[name]
151                    this.lastLetter = ''
152                } else {
153                    event.target.classList.add('active')
154                    this.params[name].values = value
155                }
156                break
157            default:
158                this.params[name].values = value
159                break
160        }
161
162        this.page = 1
163        this.fetchListItems()
164    }
165
166    /**
167     * handles load more items
168     */
169    onLoadMore() {
170        this.page++
171        this.fetchListItems(true)
172    }
173
174    /**
175     * handles the q free text search changes
176     *
177     * @param event
178     */
179    onSearchChange(event) {
180        this.fullTextSearch = event.target.value
181        // If at least 4 characters are specified in the search f
181ield, the suggestion list is retrieved.
182        if (event.target.value.length >= 4 && !this.suggestionlistOnLoad) {
183            let suggestionUri = event.target.hasAttribute('data-uri')
184                ? event.target.getAttribute('data-uri')
185                : ''
186            let withKeywords = event.target.hasAttribute(
187                'data-suggestion-with-keywords',
188            )
189                ? event.target.getAttribute('data-suggestion-with-keywords')
190                : '0'
191            let documentAsTerm = event.target.hasAttribute(
192                'data-suggestion-document-as-term',
193            )
194                ? event.target.getAttribute('data-suggestion-document-as-term')
195                : '0'
196            let suggestionLabel = event.target.hasAttribute(
197                'data-suggestion-label',
198            )
199                ? event.target.getAttribute('data-suggestion-label')
200                : 'Suggestions'
201            if (suggestionUri !== '') {
202                this.suggestionlistOnLoad = true
203                this.fetchSuggestions(
204                    suggestionUri,
205                    event.target.value,
206                    withKeywords,
207                    documentAsTerm,
208                    suggestionLabel,
209                )
210            } else {
211                console.warn(
212                    'No URL was found to retrieve the suggestion list. Check the "data-uri" attribute on "#' +
213                    event.target.getAttribute('id') +
214                    '"!',
215                )
216            }
217        }
218    }
219
220    /**
221     * handles the clicks on suggestion term
222     *
223     * @param event
224     */
225    onTermClick(event) {
226        this.page = 1
227        this.fullTextSearch = event.target.hasAttribute('data-suggestion-term')
228            ? event.target.getAttribute('data-suggestion-term')
229            : ''
230        this.searchfieldTarget.value = this.fullTextSearch
231        this.fetchListItems()
232    }
233
234    /**
235     * handles date range changes
236     */
237    onDateRangeChange(event) {
238        this.dateRangeRaw = event.target.name + '#' + event.target.value
239        let dateParts = event.target.value.split(' - ') ?? [null, null]
240        let start = '*'
241        let end = '*'
242        let attributeFrom = event.target.hasAttribute('data-type-from')
243            ? event.target.getAttribute('data-type-from')
244            : ''
245        let attributeTo = event.target.hasAttribute('data-type-to')
246            ? event.target.getAttribute('data-type-to')
247            : ''
248
249        // start date
250        if (dateParts[0] !== 'null') {
251            start = new Date(dateParts[0])
252            start =
253                start.getFullYear() +
254                '-' +
255                (start.getMonth() + 1) +
256                '-' +
257                start.getDate() +
258                'T00:00:00Z'
259        }
260
261        // end date
262        if (dateParts[1] !== 'null') {
263            end = new Date(dateParts[1])
264            end =
265                end.getFullYear() +
266                '-' +
267                (end.getMonth() + 1) +
268                '-' +
269                end.getDate() +
270                'T23:59:59Z'
271        }
272
273        if (attributeFrom === attributeTo) {
274            this.dateRange = attributeFrom + ':[' + start + ' TO ' + end + ']'
275        } else {
276            this.dateRange =
277                '(' +
278                attributeFrom +
279                ':[* TO ' +
280                end +
281                '] AND ' +
282                attributeTo +
283                ':[' +
284                start +
285                ' TO *])'
286        }
287
288        this.page = 1
289        this.fetchListItems()
290    }
291
292    setDefaultTimeRange() {
293        let start = new Date();
294        start =
295            start.getFullYear() +
296            '-' +
297            (start.getMonth() + 1) +
298            '-' +
299            start.getDate() +
300            'T00:00:00Z'
301        return '(start_dateS:[* TO *] AND end_dateS:[' + start + ' TO *])'
302    }
303
304    /**
305     * handled sorting changes
306     *
307     * @param event
308     */
309    onSortingChange(event) {
310        this.page = 1
311        this.sort = event.target.value
312        this.fetchListItems()
313    }
314
315    /**
316     * handles submit click on button and enter
317     */
318    doSearch() {
319        this.page = 1
320        this.fetchListItems()
321    }
322
323    /**
324     * handles the frequently searched words
325     *
326     * @param event
327     */
328    onFrequentlySearched(event) {
329        this.page = 1
330        this.fullTextSearch = event.target.value
331        this.searchfieldTarget.value = event.target.value
332        this.fetchListItems()
333    }
334
335    /**
336     * handles reset form click on button
337     */
338    doReset() {
339        this.params = {}
340        this.fullTextSearch = ''
341        this.page = 1
342        if (document.querySelector('[data-type-from="start_dateS"]')) {
343            this.dateRange = this.setDefaultTimeRange()
344        } else {
345            this.dateRange = ''
346        }
347        this.dateRangeRaw = ''
348        this.lastLetter = ''
349        this.type = this.hasTypeTarget ? this.typeTarget.value : ''
350        for (let i in this.facetTargets) {
351            this.facetTargets[i].classList.remove('active')
352        }
353        this.formTarget.reset()
354        this.fetchListItems()
355    }
356
357    /**
358     * Reset all checkboxes on single multiselect
359     * @param event
360     */
361    doMultiselectReset(event) {
362        let multiSelect = event.currentTarget.closest('.powermail-select-container');
363        if (multiSelect === null) return;
364
365        let checkboxes = multiSelect.querySelectorAll('input[type="checkbox"]:checked');
366        if (checkboxes.length === 0) return;
367
368        let checkboxName= '';
369        checkboxes.forEach(checkbox => {
370            checkboxName = checkbox.name;
371            checkbox.checked = false;
372        });
373        delete this.params[checkboxName];
374
375        this.page = 1
376        this.fetchListItems()
377    }
378
379    /**
380     * The facet options in the filter are updated based on the transferred fact objects.
381     * This affects the number of elements found and, if applicable,
382     * the activation and deactivation of the filter option
383     * @param facets
384     */
385    updateFacetOptions(facets) {
386        // update options
387        let options = document.querySelectorAll('[data-facet-option]');
388        options.forEach((element) => {
389            let facetName = element.dataset.type
390            let option = element.dataset.facetOption
391            let elementRoot = element.closest('div.checkbox')
392            if (elementRoot === null) elementRoot = element.closest('li.radio')
393
394            if (facets[facetName] !== undefined && facets[facetName].options[option] !== undefined && parseInt(facets[facetName].options[option].quantity) > 0) {
395                elementRoot.classList.remove('deactivated')
396                elementRoot.querySelector('.option-quantity').textContent = parseInt(facets[facetName].options[option].quantity)
397                element.disabled = false
398            } else {
399                elementRoot.classList.add('deactivated')
400                elementRoot.querySelector('.option-quantity').textContent = 0
401                element.disabled = true
402            }
403        });
404        // update multiselects
405        let multiSelectsOptions = document.querySelectorAll('.powermail_fieldwrap_type_select.multi-select .powermail-select-single-wrap ul.powermail_field li.radio:first-child [data-facet-option]');
406        multiSelectsOptions.forEach((element) => {
407            let multiSelect = element.closest('.powermail_field.powermail-select-container')
408            if (multiSelect.querySelectorAll('input[data-facet-option]:not([disabled])').length === 0) {
409                multiSelect.classList.add('deactivated')
410            } else {
411                multiSelect.classList.remove('deactivated')
412            }
413        });
414    }
415
416    executeFunctionsAfterListFetch() {
417        let keys = Object.keys(this.functionsAfterListFetch)
418        keys.forEach((key) => window[key]())
419    }
420
421    /**
422     * Builds the record-type filter shared by result and suggestion requests.
423     *
424     * @returns {string}
425     */
426    getTypeFilter() {
427        if (!this.hasTypeTarget && !this.type) {
428            return ''
429        }
430
431        let typeFilter = 'altType_stringS:' + this.type
432
433        if (
434            this.hasAdditionalFacetTypesTarget
435            && this.additionalFacetTypesTarget.value !== ''
436        ) {
437            const additionalFacetTypes =
438                this.additionalFacetTypesTarget.value.split(',')
439
440            additionalFacetTypes.forEach(
441                (facetType) =>
442                    (typeFilter += ' OR altType_stringS:' + facetType.trim()),
443            )
444
445            typeFilter = '(' + typeFilter + ')'
446        }
447
448        return typeFilter
449    }
450
451    /**
452     * Keeps structural filters out of tx_solr[q]. Newer ext-solr versions
453     * escape field, grouping and range syntax at the user-input boundary.
454     *
455     * @param data
456     * @param typeFilter
457     * @param queryFilter
458     * @param dateRangeFilter
459     */
460    addScopeFilters(
461        data,
462        typeFilter,
463        queryFilter = null,
464        dateRangeFilter = null,
465    ) {
466        if (typeFilter !== '') {
467            data['tx_solr[additionalFilters][fnnTypeScope]'] = typeFilter
468        }
469
470        if (queryFilter !== null && queryFilter !== '') {
471            data['tx_solr[additionalFilters][fnnQueryScope]'] = queryFilter
472        }
473
474        if (dateRangeFilter !== null && dateRangeFilter !== '') {
475            data['tx_solr[additionalFilters][fnnDateRange]'] = dateRangeFilter
476        }
477    }
478
479    /**
480     * fetches the results
481     */
482    fetchListItems(append = false) {
483        // hide inputs and add loading classes
484        this.toggleFieldset(true)
485        this.toggleList(true)
486        // solr-url
487        let url = new URL(this.formTarget.getAttribute('data-uri'))
488        // Disable the cache, we need a fresh list of items.
489        let headers = new Headers()
490        headers.append('Pragma', 'no-cache')
491        headers.append('Cache-Control', 'no-cache')
492
493        if (!append) {
494            this.lastLetter = ''
495        }
496
497        // preset data search object
498        let data = {
499            'tx_solr[page]': this.page,
500            'tx_solr[sort]': this.sort !== undefined ? this.sort : '',
501            'tx_solr[lastLetter]': this.lastLetter,
502            'tx_solr[hasAtoz]': this.hasAtoz,
503            'tx_solr[searchType]': this.searchType,
504        }
505
506        // reset filterNumber for new search
507        this.filterNumber = 0
508
509        // set filter
510        const typeFilter = this.getTypeFilter()
511        if (typeFilter !== '') {
512            data['tx_solr[filter][' + this.filterNumber + ']'] = typeFilter
513            this.filterNumber++
514        }
515
516        // generate filter options for facets (tx_solr[filter][*])
517        this.generateFilters(data)
518
519        // Render non-facet parameters as an untagged filter query.
520        const queryFilter = this.renderQString([])
521
522        // Keep tx_solr[q] limited to the user's search term. To preserve the
523        // legacy any-term behavior, configure search.query.minimumMatch = 1.
524        if (this.fullTextSearch.length > 0) {
525            data['tx_solr[q]'] =
526                '*' +
527                this.escapeSolrSearchTerm(this.fullTextSearch.trim()) +
528                '*'
529        }
530
531        // handle storage pids
532        if (this.hasPidTarget) {
533            let pidList = this.pidTarget.value.split(',')
534            data['tx_solr[additionalFilters][pids]'] =
535                '+(*:* AND pid:(' + pidList.join(' OR ') + '))'
536        }
537
538        this.addScopeFilters(data, typeFilter, queryFilter, this.dateRange)
539
540        // update filter counter
541        if (this.hasActiveFilterQuantityTarget) {
542            if (Object.keys(this.params).length > 0) {
543                this.activeFilterQuantityTarget.parentNode.style.removeProperty('display')
544                this.activeFilterQuantityTarget.innerHTML = Object.keys(
545                    this.params,
546                ).length
547            } else {
548                this.activeFilterQuantityTarget.parentNode.style.display = 'none'
549            }
550        }
551
552        // make formdata from data object
553        let formData = new FormData()
554        Object.keys(data).forEach((key) => formData.append(key, data[key]))
555
556        // fetch the request
557        fetch(url, {
558            method: 'POST',
559            headers: headers,
560            body: formData,
561        })
562            .then((response) => response.text())
563            .then((html) => {
564                let parser = new DOMParser()
565                let doc = parser.parseFromString(html, 'text/html')
566                let facetsString = doc.querySelector('facets')
567                if (facetsString !== null) {
568                    let facets = JSON.parse(facetsString.textContent)
569                    this.updateFacetOptions(facets)
570                }
571                let informationString = doc.querySelector('information')
572                if (informationString !== null && informationString != '' && this.hasLoadMoreInformationTarget) {
573                    this.loadMoreInformationTarget.innerText = informationString.textContent
574                }
575                let element = doc.querySelector('.results-list')
576                if (element) {
577                    // calculate next page behaviour for page browser
578                    if (
579                        Math.ceil(element.getAttribute('data-pages')) >
580                        this.page
581                    ) {
582                        this.loadMoreContainerTarget.style.removeProperty(
583                            'display',
584                        )
585                    } else {
586                        this.loadMoreContainerTarget.style.display = 'none'
587                    }
588
589                    // get atoz filter array
590                    if (this.hasAtozTarget) {
591                        if (element.getAttribute('data-firstletters-count')) {
592                            let letterCounts = JSON.parse(
593                                element.getAttribute('data-firstletters-count'),
594                            )
595                            if (letterCounts !== null) {
596                                for (const key in this.atozTargets) {
597                                    this.atozTargets[key].disabled =
598                                        letterCounts[
599                                            this.atozTargets[key].value
600                                            ] === undefined
601                                }
602                            }
603                        }
604                    }
605
606                    // Remember last letter to create letter headings in lists
607                    if (
608                        doc.querySelector(
609                            '.results-list [data-last-letter]:last-child',
610                        ) !== null
611                    ) {
612                        this.lastLetter = doc
613                            .querySelector(
614                                '.results-list [data-last-letter]:last-child',
615                            )
616                            .getAttribute('data-last-letter')
617                    }
618
619                    // append data items if set
620                    if (append) {
621                        while (element.firstChild) {
622                            this.listTarget.appendChild(element.firstChild)
623                        }
624                    } else {
625                        this.listTarget.innerHTML = element.innerHTML
626                    }
627
628                    this.listEmptyTarget.style.display = 'none'
629                    if (this.hasSortingTarget) {
630                        this.sortingTarget.style.removeProperty('display')
631                    }
632                } else {
633                    this.listTarget.innerHTML = ''
634                    this.listEmptyTarget.style.display = 'block'
635                    this.sortingTarget.style.display = 'none'
636                }
637
638                if (typeof window.FnnStimulus.controllers[1] !== 'undefined' && typeof window.FnnStimulus.controllers[1].refreshBookmarkHighlight === "function") {
639                    window.FnnStimulus.controllers[1].refreshBookmarkHighlight()
640                }
641
642                // activate inputs and remove loading classes
643                this.toggleFieldset(false)
644                this.toggleList(false)
645                this.executeFunctionsAfterListFetch()
646            })
647
648        this.updateUrl()
649    }
650
651    /**
652     * fetches the suggestions
653     */
654    fetchSuggestions(url, term, withKeywords, documentAsTerm, suggestionLabel) {
655        // Disable the cache, we need a fresh list of items.
656        let headers = new Headers()
657        headers.append('Pragma', 'no-cache')
658        headers.append('Cache-Control', 'no-cache')
659
660        // preset data search object
661        let data = {}
662        data['tx_solr[queryString]'] = term
663
664        // set initial suggest parameters
665        const typeFilter = this.getTypeFilter()
666        if (typeFilter !== '') {
667            data['tx_solr[filter][0]'] = typeFilter
668        }
669
670        // handle storage pids
671        if (this.hasPidTarget) {
672            let pidList = this.pidTarget.value.split(',')
673            data['tx_solr[additionalFilters][pids]'] =
674                '+(*:* AND pid:(' + pidList.join(' OR ') + '))'
675        }
676
677        // SuggestController applies HTML escaping to additional filters.
678        // Escape whitespace instead of quoting multi-word field values.
679        const queryFilter = this.renderQString(null, false)
680        this.addScopeFilters(data, typeFilter, queryFilter)
681
682        // make formdata from data object
683        let formData = new FormData()
684        Object.keys(data).forEach((key) => formData.append(key, data[key]))
685
686        // fetch the suggestions
687        fetch(url, {
688            method: 'POST',
689            headers: headers,
690            body: formData,
691        })
692            .then((response) => response.text())
693            .then((html) => {
694                data = JSON.parse(html)
695                // get status result
696                if (data.hasOwnProperty('status') && !data.status) {
697                    this.suggestionlistOnLoad = false
698                    return
699                }
700                let content = ''
701
702                // Create links for documents
703                let controller = this
704                let documentCounter = 1;
705                data.documents.forEach(function (document) {
706                    if (documentAsTerm === '0') {
707                        content +=
708                            '<li id="suggestion-list-document-' + documentCounter + '"><a href="' +
709                            document.link +
710                            '" class="suggestion-list-item suggestion-list-type-' +
711                            document.type +
712                            '">' +
713                            controller.buildTextWithHighlightetdTerm(
714                                document.title,
715                                term,
716                            ) +
717                            '</a></li>'
718                    } else {
719                        content +=
720                            '<button id="suggestion-list-document-' + documentCounter + '"><button role="button" class="suggestion-list-item" data-action="filter#onTermClick" data-suggestion-term="' +
721                            document.title.slice(0, 28) +
722                            '">' +
723                            controller.buildTextWithHighlightetdTerm(
724                                document.title,
725                                term,
726                            ) +
727                            '</button></li>'
728                    }
729                    documentCounter++;
730                })
731
732                // Create suggestions
733                if (withKeywords === '1') {
734                    let suggestionCounter = 0;
735                    content +=
736                        '<span class="suggestion-list-terms">' +
737                        suggestionLabel +
738                        '</span>'
739                    Object.keys(data.suggestions).forEach(
740                        (key) =>
741                            (content +=
742                                '<li id="suggestion-list-suggestion-' + (++suggestionCounter) + '"><button role="button" class="suggestion-list-item" data-action="filter#onTermClick" data-suggestion-term="' +
743                                key.slice(0, 28) +
744                                '">' +
745                                this.buildTextWithHighlightetdTerm(key, term) +
746                                '</button></li>'),
747                    )
748                }
749                // set content
750                this.suggestionlistTarget.innerHTML = content
751                this.suggestionlistTarget.classList.add('is-open')
752
753                this.searchInput.setAttribute('aria-expanded', 'true');
754                const searchInput = this.searchInput;
755
756                let suggestItems = document.querySelectorAll('.suggestion-list-item');
757                for (let suggestItem of suggestItems) {
758                    suggestItem.addEventListener('focusin', function (event) {
759                        searchInput.setAttribute('aria-activedescendant', suggestItem.parentNode.getAttribute('id'));
760                    });
761
762                    suggestItem.addEventListener('focusout', function (event) {
763                        searchInput.removeAttribute('aria-activedescendant');
764                    });
765                }
766
767                this.suggestionlistOnLoad = false
768            })
769    }
770
771    /**
772     * Special characters must be escaped with a backslash for the search.
773     * https://solr.apache.org/guide/7_3/the-standard-query-parser.html#escaping-special-characters
774     * + - && || ! ( ) { } [ ] ^ " ~ * ? : /
775     *
776     * @param term
777     * @returns {string}
778     */
779    escapeSolrSearchTerm(term) {
780        let escaped = term
781        const toEscaped = [
782            '+',
783            '-',
784            '&&',
785            '||',
786            '!',
787            '(',
788            ')',
789            '{',
790            '}',
791            '[',
792            ']',
793            '^',
794            '"',
795            '~',
796            '*',
797            '?',
798            ':',
799            '/',
800        ]
801        toEscaped.forEach(
802            (element) => (escaped = escaped.replace(element, '\\' + element)),
803        )
804
805        return escaped
806    }
807
808    /**
809     * Searches for the search term in the passed text and highlights it.
810     * In addition, the return value it is truncated to 28 characters.
811     *
812     * @param text
813     * @param term
814     * @returns {string}
815     */
816    buildTextWithHighlightetdTerm(text, term) {
817        let cropped = text.slice(0, 28)
818        let firstStart = cropped.toUpperCase().indexOf(term.toUpperCase())
819        return (
820            cropped.substring(0, firstStart) +
821            '<span class="highlight">' +
822            cropped.substring(firstStart, firstStart + term.length) +
823            '</span>' +
824            cropped.substring(firstStart + term.length)
825        )
826    }
827
828    /**
829     * renders the q search string for solr
830     *
831     * @param data {array|null}
832     * @param quoteValues {boolean}
833     * @returns {string}
834     */
835    renderQString(data, quoteValues = true) {
836        let qStringPartsString = []
837        for (const name in this.params) {
838            // All options that are not facets must be transferred as a filter query.
839            if (!this.params[name].isFacet) {
840                let qStringPartsArray = []
841                for (const value in this.params[name].values) {
842                    const filterValue = quoteValues
843                        ? '"' + this.params[name].values[value] + '"'
844                        : this.params[name].values[value].replace(/\s/g, '\\ ')
845
846                    qStringPartsArray.push(
847                        this.params[name].attribute +
848                        ':' +
849                        filterValue,
850                    )
851                }
852                if (qStringPartsArray.length > 0) {
853                    qStringPartsString.push(
854                        '(' +
855                        qStringPartsArray.join(
856                            ' ' + this.params[name].conjunction + ' ',
857                        ) +
858                        ')',
859                    )
860                }
861            }
862        }
863        if (data !== null && qStringPartsString.length > 0) {
864            qStringPartsString.forEach((element) => {
865                data['tx_solr[filter][' + this.filterNumber + ']'] = element
866                this.filterNumber++
867            })
868        }
869
870        if (this.hasAdditionalSolrQTarget) {
871            return qStringPartsString.length > 0
872                ? '(' + qStringPartsString.join(' AND ') + ') AND ' + this.additionalSolrQTarget.value
873                : this.additionalSolrQTarget.value
874        }
875
876        return qStringPartsString.length > 0
877            ? '(' + qStringPartsString.join(' AND ') + ')'
878            : null
879    }
880
881    /**
882     * generate solr search filter options for facets
883     *
884     * @param data {array}
885     */
886    generateFilters(data) {
887        for (const name in this.params) {
888            // Only facets are considered as filters
889            if (this.params[name].isFacet) {
890                let qStringPartsArray = []
891                for (const value in this.params[name].values) {
892                    qStringPartsArray.push(
893                        this.params[name].attribute +
894                        ':' +
895                        this.params[name].values[value] + '',
896                    )
897                }
898                qStringPartsArray.forEach((value) => {
899                    data['tx_solr[filter][' + this.filterNumber + ']'] = value
900                    this.filterNumber++
901                })
902            }
903        }
904    }
905
906    /**
907     * disabled all fieldset to prevent input changes while loading
908     *
909     * @param disable
910     */
911    toggleFieldset(disable) {
912        for (let key in this.fieldsetTargets) {
913            if (disable) {
914                this.fieldsetTargets[key].setAttribute('disabled', '')
915            } else {
916                this.fieldsetTargets[key].removeAttribute('disabled')
917            }
918        }
919    }
920
921    /**
922     * add classes to show loading animation and disable list while loading
923     *
924     * @param disable
925     */
926    toggleList(disable) {
927        if (disable) {
928            this.listTarget.classList.add(
929                'disabled-by-filter',
930                'loading-by-filter',
931            )
932            this.listTarget.setAttribute('disabled', '')
933        } else {
934            this.listTarget.classList.remove(
935                'disabled-by-filter',
936                'loading-by-filter',
937            )
938            this.listTarget.removeAttribute('disabled')
939        }
940    }
941
942    /**
943     * handles the overlay open
944     */
945    openOverlay() {
946        /* enable scroll lock by css */
947        document.getElementsByTagName('html')[0].classList.add('no-scroll')
948        this.overlayTarget.classList.add('is-open')
949        this.showOverlayValue = true
950        this.closeOverlayTarget.focus()
951        $(this.overlayTarget).focusTrap() /* trap focus in overlay */
952    }
953
954    /**
955     * handles the overlay close
956     */
957    closeOverlay() {
958        /* disable scroll lock by css */
959        document.getElementsByTagName('html')[0].classList.remove('no-scroll')
960
961        this.showOverlayValue = false
962        this.overlayTarget.classList.remove('is-open')
963        $(this.overlayTarget).focusTrap('untrap') /* release the focus trap */
964        this.overlayToggleTarget.focus() /* focus button that toggles the overlay */
965    }
966
967    windowKeyHandler(event) {
968        if (this.showOverlayValue) {
969            // ESCAPE key pressed
970            if (event.keyCode === 27) {
971                this.closeOverlay()
972            }
973        }
974    }
975
976    /**
977     * closes suggestions list on window click
978     *
979     * @param event
980     */
981    windowClick(event) {
982        // Hides the suggestion list when clicked outside the search box.
983        if (this.hasSearchfieldTarget && this.hasSuggestionlistTarget) {
984            if (this.searchfieldTarget.contains(event.target) === false) {
985                this.suggestionlistTarget.classList.remove('is-open')
986                this.searchInput.setAttribute('aria-expanded', 'false');
987                this.searchInput.removeAttribute('aria-activedescendant');
988                this.suggestionlistTarget.innerHTML = ''
989            } else {
990                this.fullTextSearch = this.searchfieldTarget.value
991                this.onSearchChange(event)
992            }
993        }
994    }
995
996    /**
997     * handles the url update if filter settings changed
998     */
999    updateUrl() {
1000        let url = new URL(window.location.href)
1001        let filter = btoa(
1002            JSON.stringify({
1003                params: this.params,
1004                fullTextSearch: this.fullTextSearch,
1005                //page: this.page, // With "load more" and page reload this leads to problems (lists do not start with page 1)
1006                type: this.type,
1007                dateRange: this.dateRange,
1008                dateRangeRaw: this.dateRangeRaw,
1009                sort: this.sort,
1010                lastLetter: this.lastLetter,
1011                hasAtoz: this.hasAtoz,
1012            }),
1013        )
1014        url.searchParams.set('filter', filter)
1015        window.history.pushState(null, null, url)
1016    }
1017
1018    /**
1019     * handles the filter params get by url search parameter
1020     */
1021    updateParams() {
1022        let url = new URL(window.location.href)
1023        if (url.searchParams.get('filter')) {
1024            let filter = JSON.parse(atob(url.searchParams.get('filter')))
1025            if (Object.keys(filter).length > 0) {
1026                this.params = filter.params
1027                this.fullTextSearch = filter.fullTextSearch
1028                //this.page = filter.page; // With "load more" and page reload this leads to problems (lists do not start with page 1)
1029                this.type = filter.type
1030                this.dateRange = filter.dateRange
1031                this.dateRangeRaw = filter.dateRangeRaw
1032                this.sort = filter.sort
1033                this.lastLetter = filter.lastLetter
1034                this.hasAtoz = filter.hasAtoz
1035
1036                // update sorting selector
1037                if (this.hasSortingSelectTarget) {
1038                    this.sortingSelectTarget.value = this.sort
1039                }
1040
1041                // update fulltext search
1042                if (this.hasSearchfieldTarget) {
1043                    this.searchfieldTarget.value = this.fullTextSearch
1044                }
1045
1046                // update date range
1047                if (this.dateRangeRaw) {
1048                    let dateRangeRawParts = this.dateRangeRaw.split('#')
1049                    let dateParts = dateRangeRawParts[1].split(' - ')
1050
1051                    const start = new Date(dateParts[0]).toLocaleDateString(
1052                        'de-DE',
1053                        {
1054                            day: '2-digit',
1055                            month: '2-digit',
1056                            year: 'numeric',
1057                        },
1058                    )
1059                    const end = new Date(dateParts[1]).toLocaleDateString(
1060                        'de-DE',
1061                        {
1062                            day: '2-digit',
1063                            month: '2-digit',
1064                            year: 'numeric',
1065                        },
1066                    )
1067
1068                    document.querySelector(
1069                        '#filter_' + dateRangeRawParts[0].replace('-range', ''),
1070                    ).value = start + ' - ' + end
1071                }
1072
1073                // update params based inputs
1074                Object.keys(this.params).forEach((key) => {
1075                    let targets = document.querySelectorAll(
1076                        '[name="' + key + '"]',
1077                    )
1078                    targets.forEach((target) => {
1079                        // handle inputs by name
1080                        if (
1081                            target.getAttribute('data-type') ===
1082                            this.params[key].attribute
1083                        ) {
1084                            // checkboxes
1085                            if (target.type === 'checkbox') {
1086                                for (let i in this.params[key].values) {
1087                                    if (
1088                                        target.value ===
1089                                        this.params[key].values[i]
1090                                    ) {
1091                                        target.checked = true
1092                                    }
1093                                }
1094                            }
1095
1096                            // buttons
1097                            if (target.type === 'button') {
1098                                if (target.value === this.params[key].values) {
1099                                    target.classList.add('active')
1100                                }
1101                            }
1102                        }
1103                    })
1104                })
1105            }
1106        }
1107    }
1108}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.