grr

Base library for my scripts

Ezt a szkriptet nem ajánlott közvetlenül telepíteni. Ez egy könyvtár más szkriptek számára, amik tartalmazzák a // @require https://update.greasyfork.org/scripts/411595/849664/grr.js hivatkozást.

// ==UserScript==
// @name          grr
// @namespace     brazenvoid
// @version       1.0
// @author        brazenvoid
// @license       GPL-3.0-only
// @description   Base library for my scripts
// @grant         GM_addStyle
// @run-at  	  document-end
// ==/UserScript==

/**
 * @function GM_addStyle
 * @param {string} style
 */
GM_addStyle(
    `@keyframes fadeEffect{from{opacity:0}to{opacity:1}}button.form-button{padding:0 5px;width:100%}button.show-settings{background-color:#ffa31a;border:0;margin:2px 5px;padding:2px 5px;width:100%}button.show-settings.fixed{color:#000;font-size:.7rem;left:0;height:90vh;margin:0;padding:0;position:fixed;top:5vh;width:.1vw;writing-mode:sideways-lr;z-index:999}button.tab-button{background-color:inherit;border:1px solid #000;border-bottom:0;border-top-left-radius:3px;border-top-right-radius:3px;cursor:pointer;float:left;outline:none;padding:5px 10px;transition:.3s}button.tab-button:hover{background-color:#fff}button.tab-button.active{background-color:#fff;display:block}div.form-actions{text-align:center}div.form-actions button.form-button{padding:0 15px;width:auto}div.form-actions-wrapper{display:inline-flex}div.form-actions-wrapper > div.form-group + *{margin-left:15px}div.form-group{min-height:15px;padding:4px 0}div.form-group.form-range-input-group > input{padding:0 5px;width:70px}div.form-group.form-range-input-group > input + input{margin-right:5px}div.form-section{text-align:center}div.form-section button + button{margin-left:5px}div.form-section label.title{display:block;height:20px;width:100%}div.form-section button.form-button{width:auto}div.tab-panel{animation:fadeEffect 1s;border:1px solid #000;display:none;padding:5px 10px}div.tab-panel.active{display:block}div.tabs-nav{overflow:hidden}div.tabs-section{margin-bottom:5px}hr{margin:3px}input.form-input{height:18px;text-align:center}input.form-input.check-radio-input{float:left;margin-right:5px}input.form-input.regular-input{float:right;width:100px}label.form-label{padding:2px 0}label.form-label.regular-input{float:left}label.form-label.check-radio-input{float:left}label.form-stat-label{float:right;padding:2px 0}section.form-section{color:#000;font-size:12px;font-weight:700;position:fixed;left:0;padding:5px 10px;z-index:1000}select.form-dropdown{float:right;height:18px;text-align:center;width:100px}textarea.form-input{display:block;height:auto;position:relative;width:98%}`)

/**
 * @param milliseconds
 * @return {Promise<*>}
 */
const sleep = (milliseconds) => {
    return new Promise(resolve => setTimeout(resolve, milliseconds))
}

/**
 * @param {string} text
 * @return {string}
 */
function toKebabCase (text)
{
    return text.toLowerCase().replace(' ', '-')
}

class ChildObserver
{
    /**
     * @callback observerOnMutation
     * @param {NodeList} nodes
     */

    /**
     * @return {ChildObserver}
     */
    static create ()
    {
        return new ChildObserver
    }

    /**
     * ChildObserver constructor
     */
    constructor ()
    {
        this._node = null
        this._observer = null
        this._onNodesAdded = null
        this._onNodesRemoved = null
    }

    /**
     * @return {ChildObserver}
     * @private
     */
    _observeNodes ()
    {
        this._observer.observe(this._node, {childList: true})
        return this
    }

    /**
     * Attach an observer to the specified node(s)
     * @param {Node} node
     * @returns {ChildObserver}
     */
    observe (node)
    {
        this._node = node
        this._observer = new MutationObserver((mutations) => {
            for (let mutation of mutations) {
                if (mutation.addedNodes.length && this._onNodesAdded !== null) {
                    this._onNodesAdded(
                        mutation.addedNodes,
                        mutation.previousSibling,
                        mutation.nextSibling,
                        mutation.target,
                    )
                }
                if (mutation.removedNodes.length && this._onNodesRemoved !== null) {
                    this._onNodesRemoved(
                        mutation.removedNodes,
                        mutation.previousSibling,
                        mutation.nextSibling,
                        mutation.target,
                    )
                }
            }
        })
        return this._observeNodes()
    }

    /**
     * @param {observerOnMutation} eventHandler
     * @returns {ChildObserver}
     */
    onNodesAdded (eventHandler)
    {
        this._onNodesAdded = eventHandler
        return this
    }

    /**
     * @param {observerOnMutation} eventHandler
     * @returns {ChildObserver}
     */
    onNodesRemoved (eventHandler)
    {
        this._onNodesRemoved = eventHandler
        return this
    }

    pauseObservation ()
    {
        this._observer.disconnect()
    }

    resumeObservation ()
    {
        this._observeNodes()
    }
}

class LocalStore
{
    /**
     * @callback storeEventHandler
     * @param {Object} store
     */

    /**
     * @param {string} scriptPrefix
     * @param {Object} defaults
     * @return {LocalStore}
     */
    static createGlobalConfigStore (scriptPrefix, defaults)
    {
        return new LocalStore(scriptPrefix + 'globals', defaults)
    }

    static createPresetConfigStore (scriptPrefix, defaults)
    {
        return new LocalStore(scriptPrefix + 'presets', [
            {
                name: 'default',
                config: defaults,
            },
        ])
    }

    /**
     * @param {string} key
     * @param {Object} defaults
     */
    constructor (key, defaults)
    {
        /**
         * @type {string}
         * @private
         */
        this._key = key

        /**
         * @type {Object}
         * @private
         */
        this._store = {}

        /**
         * @type {string}
         * @private
         */
        this._defaults = this._toJSON(defaults)

        /**
         * @type {storeEventHandler}
         */
        this._onChange = null
    }

    /**
     * @param {string} json
     * @return {Object}
     * @private
     */
    _fromJSON (json)
    {
        /** @type {{arrays: Object, objects: Object, properties: Object}} */
        let parsedJSON = JSON.parse(json)
        let arrayObject = {}
        let store = {}

        for (let property in parsedJSON.arrays) {
            arrayObject = JSON.parse(parsedJSON.arrays[property])
            store[property] = []

            for (let key in arrayObject) {
                store[property].push(arrayObject[key])
            }
        }
        for (let property in parsedJSON.objects) {
            store[property] = this._fromJSON(parsedJSON.objects[property])
        }
        for (let property in parsedJSON.properties) {
            store[property] = parsedJSON.properties[property]
        }
        return store
    }

    /**
     * @return {string}
     * @private
     */
    _getStore ()
    {
        return window.localStorage.getItem(this._key)
    }

    /**
     * @return {Object}
     * @private
     */
    _getDefaults ()
    {
        return this._fromJSON(this._defaults)
    }

    /**
     * @param {Object} store
     * @return {string}
     * @private
     */
    _toJSON (store)
    {
        let arrayToObject = {}
        let json = {arrays: {}, objects: {}, properties: {}}

        for (let property in store) {
            if (typeof store[property] === 'object') {
                if (Array.isArray(store[property])) {
                    for (let key in store[property]) {
                        arrayToObject[key] = store[property][key]
                    }
                    json.arrays[property] = JSON.stringify(arrayToObject)
                } else {
                    json.objects[property] = this._toJSON(store[property])
                }
            } else {
                json.properties[property] = store[property]
            }
        }
        return JSON.stringify(json)
    }

    _handleOnChange ()
    {
        if (this._onChange !== null) {
            this._onChange(this._store)
        }
    }

    /**
     * @return {LocalStore}
     */
    delete ()
    {
        window.localStorage.removeItem(this._key)
        return this
    }

    /**
     * @return {*}
     */
    get ()
    {
        return this._store
    }

    /**
     * @return {boolean}
     */
    isPurged ()
    {
        return this._getStore() === null
    }

    /**
     * @param {storeEventHandler} handler
     * @return {LocalStore}
     */
    onChange (handler)
    {
        this._onChange = handler
        return this
    }

    /**
     * @return {LocalStore}
     */
    restoreDefaults ()
    {
        this._store = this._getDefaults()
        this._handleOnChange()
        return this
    }

    /**
     * @return {LocalStore}
     */
    retrieve ()
    {
        let storedStore = this._getStore()
        if (storedStore === null) {
            this.restoreDefaults()
        } else {
            this._store = this._fromJSON(storedStore)
        }
        this._handleOnChange()
        return this
    }

    /**
     * @return {LocalStore}
     */
    save ()
    {
        window.localStorage.setItem(this._key, this._toJSON(this._store))
        this._handleOnChange()
        return this
    }

    /**
     * @param {*} data
     * @return {LocalStore}
     */
    update (data)
    {
        this._store = data
        return this.save()
    }
}

class SelectorGenerator
{
    /**
     * @param {string} selectorPrefix
     */
    constructor (selectorPrefix)
    {
        /**
         * @type {string}
         * @private
         */
        this._prefix = selectorPrefix
    }

    /**
     * @param {string} selector
     * @return {string}
     */
    getSelector (selector)
    {
        return this._prefix + selector
    }

    /**
     * @param {string} settingName
     * @return {string}
     */
    getSettingsInputSelector (settingName)
    {
        return this.getSelector(toKebabCase(settingName) + '-setting')
    }

    /**
     * @param {string} settingName
     * @param {boolean} getMinInputSelector
     * @return {string}
     */
    getSettingsRangeInputSelector (settingName, getMinInputSelector)
    {
        return this.getSelector(toKebabCase(settingName) + (getMinInputSelector ? '-min' : '-max') + '-setting')
    }

    /**
     * @param {string} statisticType
     * @return {string}
     */
    getStatLabelSelector (statisticType)
    {
        return this.getSelector(toKebabCase(statisticType) + '-stat')
    }
}

class StatisticsRecorder
{
    /**
     * @param {string} selectorPrefix
     */
    constructor (selectorPrefix)
    {
        /**
         * @type {SelectorGenerator}
         * @private
         */
        this._selectorGenerator = new SelectorGenerator(selectorPrefix)

        /**
         * @type {{Total: number}}
         * @private
         */
        this._statistics = {Total: 0}
    }

    /**
     * @param {string} statisticType
     * @param {boolean} validationResult
     * @param {number} value
     */
    record (statisticType, validationResult, value = 1)
    {
        if (!validationResult) {
            if (typeof this._statistics[statisticType] !== 'undefined') {
                this._statistics[statisticType] += value
            } else {
                this._statistics[statisticType] = value
            }
            this._statistics.Total += value
        }
    }

    reset ()
    {
        for (const statisticType in this._statistics) {
            this._statistics[statisticType] = 0
        }
    }

    updateUI ()
    {
        let label, labelSelector

        for (const statisticType in this._statistics) {
            labelSelector = this._selectorGenerator.getStatLabelSelector(statisticType)
            label = document.getElementById(labelSelector)
            if (label !== null) {
                label.textContent = this._statistics[statisticType]
            }
        }
    }
}

class UIGenerator
{
    /**
     * @param {HTMLElement|Node} node
     */
    static appendToBody (node)
    {
        document.getElementsByTagName('body')[0].appendChild(node)
    }

    /**
     * @param {HTMLElement} node
     * @param {HTMLElement[]} children
     * @return {HTMLElement}
     */
    static populateChildren (node, children)
    {
        for (let child of children) {
            node.appendChild(child)
        }
        return node
    }

    /**
     * @param {boolean} showUI
     * @param {string} selectorPrefix
     */
    constructor (showUI, selectorPrefix)
    {
        /**
         * @type {*}
         * @private
         */
        this._buttonBackroundColor = null

        /**
         * @type {HTMLElement}
         * @private
         */
        this._section = null

        /**
         * @type {SelectorGenerator}
         * @private
         */
        this._selectorGenerator = new SelectorGenerator(selectorPrefix)

        /**
         * @type {string}
         * @private
         */
        this._selectorPrefix = selectorPrefix

        /**
         * @type {boolean}
         * @private
         */
        this._showUI = showUI

        /**
         * @type {HTMLLabelElement}
         * @private
         */
        this._statusLine = null

        /**
         * @type {string}
         * @private
         */
        this._statusText = ''
    }

    /**
     * @param {HTMLElement} node
     * @param {string} text
     * @return {this}
     * @private
     */
    _addHelpTextOnHover (node, text)
    {
        node.addEventListener('mouseover', () => this.updateStatus(text, true))
        node.addEventListener('mouseout', () => this.resetStatus())
    }

    /**
     * @param {HTMLElement[]} children
     * @return {HTMLElement}
     */
    addSectionChildren (children)
    {
        return UIGenerator.populateChildren(this._section, children)
    }

    /**
     * @return {HTMLBRElement}
     */
    createBreakSeparator ()
    {
        return document.createElement('br')
    }

    /**
     * @param {HTMLElement[]} children
     * @return {HTMLDivElement}
     */
    createFormActions (children)
    {
        let wrapperDiv = document.createElement('div')
        wrapperDiv.classList.add('form-actions-wrapper')

        UIGenerator.populateChildren(wrapperDiv, children)

        let formActionsDiv = document.createElement('div')
        formActionsDiv.classList.add('form-actions')
        formActionsDiv.appendChild(wrapperDiv)

        return formActionsDiv
    }

    /**
     * @param {string} caption
     * @param {EventListenerOrEventListenerObject} onClick
     * @param {string} hoverHelp
     * @return {HTMLButtonElement}
     */
    createFormButton (caption, onClick, hoverHelp = '')
    {
		console.log (caption);

        let button = document.createElement('button')
		if (caption == 'Apply')
		{
        button.classList.add('grrapp')
		} else {
		button.classList.add('formapp')
		}
        button.textContent = caption
        button.addEventListener('click', onClick)

        if (hoverHelp !== '') {
            this._addHelpTextOnHover(button, hoverHelp)
        }
        if (this._buttonBackroundColor !== null) {
            button.style.backgroundColor = this._buttonBackroundColor
        }
        return button
    }

    /**
     * @param {HTMLElement[]} children
     * @return {HTMLElement}
     */
    createFormGroup (children)
    {
        let divFormGroup = document.createElement('div')
        divFormGroup.classList.add('form-group')

        return UIGenerator.populateChildren(divFormGroup, children)
    }

    /**
     * @param {string} id
     * @param {Array} keyValuePairs
     * @param {*} defaultValue
     * @return {HTMLSelectElement}
     */
    createFormGroupDropdown (id, keyValuePairs, defaultValue = null)
    {
        let dropdown = document.createElement('select'), item
        dropdown.id = id
        dropdown.classList.add('form-dropdown')

        for (let [key, value] of keyValuePairs) {
            item = document.createElement('option')
            item.textContent = value
            item.value = key
            dropdown.appendChild(item)
        }
        dropdown.value = defaultValue === null ? keyValuePairs[0][0] : defaultValue

        return dropdown
    }

    /**
     * @param {string} id
     * @param {string} type
     * @param {*} defaultValue
     * @return {HTMLInputElement}
     */
    createFormGroupInput (id, type, defaultValue = null)
    {
        let inputFormGroup = document.createElement('input')
        inputFormGroup.id = id
        inputFormGroup.classList.add('form-input')
        inputFormGroup.type = type

        switch (type) {
            case 'number':
            case 'text':
                inputFormGroup.classList.add('regular-input')

                if (defaultValue !== null) {
                    inputFormGroup.value = defaultValue
                }
                break
            case 'radio':
            case 'checkbox':
                inputFormGroup.classList.add('check-radio-input')

                if (defaultValue !== null) {
                    inputFormGroup.checked = defaultValue
                }
                break
        }
        return inputFormGroup
    }

    /**
     * @param {string} label
     * @param {string} inputID
     * @param {string} inputType
     * @return {HTMLLabelElement}
     */
    createFormGroupLabel (label, inputID = '', inputType = '')
    {
        let labelFormGroup = document.createElement('label')
        labelFormGroup.classList.add('form-label')
        labelFormGroup.textContent = label

        if (inputID !== '') {
            labelFormGroup.setAttribute('for', inputID)
        }
        if (inputType !== '') {
            switch (inputType) {
                case 'number':
                case 'text':
                    labelFormGroup.classList.add('regular-input')
                    labelFormGroup.textContent += ': '
                    break
                case 'radio':
                case 'checkbox':
                    labelFormGroup.classList.add('check-radio-input')
                    break
            }
        }
        return labelFormGroup
    }

    /**
     * @param {string} statisticType
     * @return {HTMLLabelElement}
     */
    createFormGroupStatLabel (statisticType)
    {
        let labelFormGroup = document.createElement('label')
        labelFormGroup.id = this._selectorGenerator.getStatLabelSelector(statisticType)
        labelFormGroup.classList.add('form-stat-label')
        labelFormGroup.textContent = '0'

        return labelFormGroup
    }

    /**
     * @param {string} label
     * @param {string} inputType
     * @param {string} hoverHelp
     * @param {*} defaultValue
     * @return {HTMLElement}
     */
    createFormInputGroup (label, inputType = 'text', hoverHelp = '', defaultValue = null)
    {
        let divFormInputGroup
        let inputID = this._selectorGenerator.getSettingsInputSelector(label)
        let labelFormGroup = this.createFormGroupLabel(label, inputID, inputType)
        let inputFormGroup = this.createFormGroupInput(inputID, inputType, defaultValue)

        switch (inputType) {
            case 'number':
            case 'text':
                divFormInputGroup = this.createFormGroup([labelFormGroup, inputFormGroup])
                break
            case 'radio':
            case 'checkbox':
                divFormInputGroup = this.createFormGroup([inputFormGroup, labelFormGroup])
                break
        }
        if (hoverHelp !== '') {
            this._addHelpTextOnHover(divFormInputGroup, hoverHelp)
        }
		
        return divFormInputGroup
    }

    /**
     * @param {string} label
     * @param {string} inputsType
     * @param {int[]|string[]} defaultValues
     * @return {HTMLElement}
     */
    createFormRangeInputGroup (label, inputsType = 'text', defaultValues = [])
    {
        let maxInputSelector = this._selectorGenerator.getSettingsRangeInputSelector(label, false)
        let minInputSelector = this._selectorGenerator.getSettingsRangeInputSelector(label, true)

        let divFormInputGroup = this.createFormGroup([
            this.createFormGroupLabel(label, '', inputsType),
            this.createFormGroupInput(maxInputSelector, inputsType, defaultValues.length ? defaultValues[1] : null),
            this.createFormGroupInput(minInputSelector, inputsType, defaultValues.length ? defaultValues[0] : null),
        ])
        divFormInputGroup.classList.add('form-range-input-group')

        return divFormInputGroup
    }

    /**
     * @param {string} title
     * @param {HTMLElement[]} children
     * @return {HTMLElement|HTMLDivElement}
     */
    createFormSection (title, children)
    {
        let sectionDiv = document.createElement('div')
        sectionDiv.classList.add('form-section')

        if (title !== '') {
            let sectionTitle = document.createElement('label')
            sectionTitle.textContent = title
            sectionTitle.classList.add('title')
            UIGenerator.populateChildren(sectionDiv, [sectionTitle])
        }
        return UIGenerator.populateChildren(sectionDiv, children)
    }

    /**
     * @param {string} caption
     * @param {string} tooltip
     * @param {EventListenerOrEventListenerObject} onClick
     * @param {string} hoverHelp
     * @return {HTMLButtonElement}
     */
    createFormSectionButton (caption, tooltip, onClick, hoverHelp = '', grr= '')
    {
        let button = this.createFormButton(caption, onClick, hoverHelp, grr)
        button.title = tooltip

        return button
    }

    /**
     * @param {string} label
     * @param {int} rows
     * @param {string} hoverHelp
     * @param {string} defaultValue
     * @return {HTMLElement}
     */
    createFormTextAreaGroup (label, rows, hoverHelp = '', defaultValue = '')
    {
        let labelElement = this.createFormGroupLabel(label)
        labelElement.style.textAlign = 'center'

        let textAreaElement = document.createElement('textarea')
        textAreaElement.id = this._selectorGenerator.getSettingsInputSelector(label)
        textAreaElement.classList.add('form-input')
        textAreaElement.value = defaultValue
        textAreaElement.setAttribute('rows', rows.toString())

        let group = this.createFormGroup([labelElement, textAreaElement])

        if (hoverHelp !== '') {
            this._addHelpTextOnHover(group, hoverHelp)
        }
        return group
    }

    /**
     * @param {string} IDSuffix
     * @param {*} backgroundColor
     * @param {*} top
     * @param {*} width
     * @return {this}
     */
    createSection (IDSuffix, backgroundColor, top, width)
    {
        this._section = document.createElement('section')
        this._section.id = this._selectorGenerator.getSelector(IDSuffix)
        this._section.classList.add('form-section')
        this._section.style.display = this._showUI ? 'block' : 'none'
        this._section.style.top = top
        this._section.style.width = width
        this._section.style.backgroundColor = backgroundColor

        return this
    }

    /**
     * @return {HTMLHRElement}
     */
    createSeparator ()
    {
        return document.createElement('hr')
    }

    /**
     * @param {LocalStore} localStore
     * @param {EventListenerOrEventListenerObject|Function} onClick
     * @param {boolean} addTopPadding
     * @return {HTMLDivElement}
     */
    createSettingsFormActions (localStore, onClick, addTopPadding = false)
    {
        let divFormActions = this.createFormSection('', [
            this.createFormActions([
                this.createFormButton('Apply', onClick, 'Filter items as per the settings in the dialog.', 'derpapply'),
                this.createFormButton('Reset', () => {
                    localStore.retrieve()
                    onClick()
                }, 'Restore and apply saved configuration.', 'derpreset'),
            ]),
        ])
        if (addTopPadding) {
            divFormActions.style.paddingTop = '10px'
        }
        return divFormActions
    }

    /**
     * @param {string} label
     * @param {Array} keyValuePairs
     * @param {*} defaultValue
     * @return {HTMLElement}
     */
    createSettingsDropDownFormGroup (label, keyValuePairs, defaultValue = null)
    {
        let dropdownID = this._selectorGenerator.getSettingsInputSelector(label)

        return this.createFormGroup([
            this.createFormGroupLabel(label, dropdownID, 'text'),
            this.createFormGroupDropdown(dropdownID, keyValuePairs, defaultValue),
        ])
    }

    /**
     * @return {HTMLButtonElement}
     */
    createSettingsHideButton ()
    {
        let section = this._section
        return this.createFormButton('<< Hide', () => section.style.display = 'none')
    }

    /**
     * @param {string} caption
     * @param {HTMLElement} settingsSection
     * @param {boolean} fixed
     * @param {EventListenerOrEventListenerObject|Function|null} onMouseLeave
     * @return {HTMLButtonElement}
     */
    createSettingsShowButton (caption, settingsSection, fixed = true, onMouseLeave = null)
    {
        let controlButton = document.createElement('button')
        controlButton.textContent = caption
        controlButton.classList.add('show-settings')

        if (fixed) {
            controlButton.classList.add('fixed')
        }
        controlButton.addEventListener('click', () => {
            let settingsUI = document.getElementById(settingsSection.id)
            settingsUI.style.display = settingsUI.style.display === 'none' ? 'block' : 'none'
        })
        settingsSection.addEventListener('mouseleave', onMouseLeave ? () => onMouseLeave() : () => settingsSection.style.display = 'none')

        return controlButton
    }

    /**
     * @param {string} statisticsType
     * @param {string} label
     * @return {HTMLElement}
     */
    createStatisticsFormGroup (statisticsType, label = '')
    {
        if (label === '') {
            label = statisticsType
        }
        return this.createFormGroup([
            this.createFormGroupLabel(label + ' Filter'),
            this.createFormGroupStatLabel(statisticsType),
        ])
    }

    /**
     * @return {HTMLElement}
     */
    createStatisticsTotalsGroup ()
    {
        return this.createFormGroup([
            this.createFormGroupLabel('Total'),
            this.createFormGroupStatLabel('Total'),
        ])
    }

    /**
     * @return {HTMLElement|HTMLDivElement}
     */
    createStatusSection ()
    {
        this._statusLine = this.createFormGroupLabel('Status')
        this._statusLine.id = this._selectorGenerator.getSelector('status')

        return this.createFormSection('', [this._statusLine])
    }

    /**
     * @param {LocalStore} localStore
     * @return {HTMLElement}
     */
    createStoreFormSection (localStore)
    {
        return this.createFormSection('Cached Configuration', [
            this.createFormActions([
                this.createFormSectionButton(
                    'Update', 'Save UI settings in store', () => localStore.save(), 'Saves applied settings.'),
                this.createFormSectionButton(
                    'Purge', 'Purge store', () => localStore.delete(), 'Removes saved settings. Settings will then be sourced from the defaults defined in the script.'),
            ]),
        ])
    }

    /**
     * @param {string} tabName
     * @return {HTMLButtonElement}
     */
    createTabButton (tabName)
    {
        let button = document.createElement('button')
        button.classList.add('tab-button')
        button.textContent = tabName
        button.addEventListener('click', (event) => {

            let button = event.currentTarget
            let tabsSection = button.closest('.tabs-section')
            let tabToOpen = tabsSection.querySelector('#' + toKebabCase(tabName))

            for (let tabButton of tabsSection.querySelectorAll('.tab-button')) {
                tabButton.classList.remove('active')
            }
            for (let tabPanel of tabsSection.querySelectorAll('.tab-panel')) {
                tabPanel.classList.remove('active')
            }

            button.classList.add('active')
            tabToOpen.classList.add('active')
        })
        return button
    }

    /**
     * @param {string} tabName
     * @param {HTMLElement[]} children
     * @return {HTMLElement|HTMLDivElement}
     */
    createTabPanel (tabName, children)
    {
        let panel = document.createElement('div')
        panel.id = toKebabCase(tabName)
        panel.classList.add('tab-panel')

        return UIGenerator.populateChildren(panel, children)
    }

    /**
     * @param {string[]} tabNames
     * @param {HTMLElement[]} tabPanels
     * @return {HTMLElement|HTMLDivElement}
     */
    createTabsSection (tabNames, tabPanels)
    {
        let wrapper = document.createElement('div')
        wrapper.classList.add('tabs-section')

        let tabsDiv = document.createElement('div')
        tabsDiv.classList.add('tabs-nav')

        let tabButtons = []
        for (let tabName of tabNames) {
            tabButtons.push(this.createTabButton(tabName))
        }

        UIGenerator.populateChildren(tabsDiv, tabButtons)
        UIGenerator.populateChildren(wrapper, [tabsDiv, ...tabPanels])
        tabButtons[0].click()

        return wrapper
    }

    /**
     * @param {string} label
     * @return {HTMLElement}
     */
    getSettingsInput (label)
    {
        return document.getElementById(this._selectorGenerator.getSettingsInputSelector(label))
    }

    /**
     * @param {string} label
     * @return {boolean}
     */
    getSettingsInputCheckedStatus (label)
    {
        return this.getSettingsInput(label).checked
    }

    /**
     * @param {string} label
     * @return {*}
     */
    getSettingsInputValue (label)
    {
        return this.getSettingsInput(label).value
    }

    /**
     * @param {string} label
     * @param {boolean} getMinInput
     * @return {HTMLElement}
     */
    getSettingsRangeInput (label, getMinInput)
    {
        return document.getElementById(this._selectorGenerator.getSettingsRangeInputSelector(label, getMinInput))
    }

    /**
     * @param {string} label
     * @param {boolean} getMinInputValue
     * @return {*}
     */
    getSettingsRangeInputValue (label, getMinInputValue)
    {
        return this.getSettingsRangeInput(label, getMinInputValue).value
    }

    resetStatus ()
    {
        this._statusLine.textContent = this._statusText
    }

    /**
     * @param {string} label
     * @param {boolean} bool
     */
    setSettingsInputCheckedStatus (label, bool)
    {
        this.getSettingsInput(label).checked = bool
    }

    /**
     * @param {string} label
     * @param {*} value
     */
    setSettingsInputValue (label, value)
    {
        this.getSettingsInput(label).value = value
    }

    /**
     * @param {string} label
     * @param {number} lowerBound
     * @param {number} upperBound
     */
    setSettingsRangeInputValue (label, lowerBound, upperBound)
    {
        this.getSettingsRangeInput(label, true).value = lowerBound
        this.getSettingsRangeInput(label, false).value = upperBound
    }

    /**
     * @param {string} status
     * @param {boolean} transient
     */
    updateStatus (status, transient = false)
    {
        if (!transient) {
            this._statusText = status
        }
        this._statusLine.textContent = status
    }
}

class Validator
{
    static iFramesRemover ()
    {
        GM_addStyle(' iframe { display: none !important; } ')
    }

    /**
     * @param {StatisticsRecorder} statisticsRecorder
     */
    constructor (statisticsRecorder)
    {
        /**
         * @type {Array}
         * @private
         */
        this._filters = []

        /**
         * @type {RegExp|null}
         * @private
         */
        this._optimizedBlacklist = null

        /**
         * @type {Object}
         * @private
         */
        this._optimizedSanitizationRules = {}

        /**
         * @type {StatisticsRecorder}
         * @private
         */
        this._statisticsRecorder = statisticsRecorder
    }

    _buildWholeWordMatchingRegex (words)
    {
        let patternedWords = []
        for (let i = 0; i < words.length; i++) {
            patternedWords.push('\\b' + words[i] + '\\b')
        }
        return new RegExp('(' + patternedWords.join('|') + ')', 'gi')
    }

    /**
     * @param {string} text
     * @return {string}
     */
    sanitize (text)
    {
        for (const substitute in this._optimizedSanitizationRules) {
            text = text.replace(this._optimizedSanitizationRules[substitute], substitute)
        }
        return text.trim()
    }

    /**
     * @param {HTMLElement} textNode
     * @return {Validator}
     */
    sanitizeTextNode (textNode)
    {
        textNode.textContent = this.sanitize(textNode.textContent)
        return this
    }

    /**
     * @param {string} selector
     * @return {Validator}
     */
    sanitizeNodeOfSelector (selector)
    {
        let node = document.querySelector(selector)
        if (node) {
            let sanitizedText = this.sanitize(node.textContent)
            node.textContent = sanitizedText
            document.title = sanitizedText
        }
        return this
    }

    /**
     * @param {string[]} blacklistedWords
     * @return {Validator}
     */
    setBlacklist (blacklistedWords)
    {
        this._optimizedBlacklist = blacklistedWords.length ? this._buildWholeWordMatchingRegex(blacklistedWords) : null
        return this
    }

    /**
     * @param {Object} sanitizationRules
     * @return {Validator}
     */
    setSanitizationRules (sanitizationRules)
    {
        for (const substitute in sanitizationRules) {
            this._optimizedSanitizationRules[substitute] = this._buildWholeWordMatchingRegex(sanitizationRules[substitute])
        }
        return this
    }

    /**
     * @param {string} text
     * @return {boolean}
     */
    validateBlackList (text)
    {
        let validationCheck = true

        if (this._optimizedBlacklist) {
            validationCheck = text.match(this._optimizedBlacklist) === null
            this._statisticsRecorder.record('Blacklist', validationCheck)
        }
        return validationCheck
    }

    /**
     * @param {string} name
     * @param {Node|HTMLElement} item
     * @param {string} selector
     * @return {boolean}
     */
    validateNodeExistence (name, item, selector)
    {
        let validationCheck = item.querySelector(selector) !== null
        this._statisticsRecorder.record(name, validationCheck)

        return validationCheck
    }

    /**
     * @param {string} name
     * @param {Node|HTMLElement} item
     * @param {string} selector
     * @return {boolean}
     */
    validateNodeNonExistence (name, item, selector)
    {
        let validationCheck = item.querySelector(selector) === null
        this._statisticsRecorder.record(name, validationCheck)

        return validationCheck
    }

    /**
     * @param {string} name
     * @param {number} value
     * @param {number[]} bounds
     * @return {boolean}
     */
    validateRange (name, value, bounds)
    {
        let validationCheck = true

        if (bounds[0] > 0 && bounds[1] > 0) {
            validationCheck = value >= bounds[0] && value <= bounds[1]
        } else {
            if (bounds[0] > 0) {
                validationCheck = value >= bounds[0]
            }
            if (bounds[1] > 0) {
                validationCheck = value <= bounds[1]
            }
        }
        this._statisticsRecorder.record(name, validationCheck)

        return validationCheck
    }

    /**
     * @param {string} name
     * @param {number} lowerBound
     * @param {number} upperBound
     * @param getValueCallback
     * @return {boolean}
     */
    validateRangeFilter (name, lowerBound, upperBound, getValueCallback)
    {
        if (lowerBound > 0 || upperBound > 0) {
            return this.validateRange(name, getValueCallback(), [lowerBound, upperBound])
        }
        return true
    }
}

class PresetSwitcher
{
    /**
     * @param {string} scriptPrefix
     * @param {Object} defaultPreset
     * @param {Object} globalConfiguration
     */
    static create (scriptPrefix, defaultPreset, globalConfiguration)
    {
        return new PresetSwitcher(scriptPrefix, defaultPreset, globalConfiguration)
    }

    /**
     * @param {string} scriptPrefix
     * @param {Object} defaultPreset
     * @param {Object} globalConfiguration
     */
    constructor (scriptPrefix, defaultPreset, globalConfiguration)
    {
        /**
         * @type {Object}
         * @private
         */
        this._appliedPreset = null

        /**
         * @type {Object}
         * @private
         */
        this._defaultPreset = defaultPreset

        /**
         * {LocalStore}
         */
        this._globalConfigurationStore = LocalStore.createGlobalConfigStore(scriptPrefix, globalConfiguration)

        /**
         * {Object}
         */
        this._globalConfiguration = this._globalConfigurationStore.retrieve().get()

        /**
         * @type {LocalStore}
         * @private
         */
        this._presetsStore = LocalStore.createPresetConfigStore(scriptPrefix, defaultPreset)

        /**
         * @type {{name: string, config: Object}[]}
         * @private
         */
        this._presets = this._presetsStore.retrieve().get()

        /**
         * @type {string}
         * @private
         */
        this._scriptPrefix = scriptPrefix
    }

    /**
     * @param {string} name
     * @param {Object} config
     * @return {this}
     */
    createPreset (name, config)
    {
        this._presets.push({
            name: name,
            config: config,
        })
        this._presetsStore.update(this._presets)
        return this
    }

    /**
     * @param {string} name
     * @return {this}
     */
    deletePreset (name)
    {
        for (let i = 0; i < this._presets.length; i++) {
            if (this._presets[i].name === name) {
                this._presets.splice(i, 1)
                this._presetsStore.update(this._presets)
                break
            }
        }
        return this
    }

    /**
     * @param name
     * @return {{name: string, config: Object}|null}
     */
    findPreset (name)
    {
        for (let preset of this._presets) {
            if (preset.name === name) {
                return preset
            }
        }
        return null
    }

    /**
     * @return {{name: string, config: Object}}
     */
    getAppliedPreset ()
    {
        return this._appliedPreset
    }
}

class BaseHandler
{
    static initialize ()
    {
        BaseHandler.throwOverrideError()
        //return (new XNXXSearchFilters).init()
    }

    static throwOverrideError ()
    {
        throw new Error('override this method')
    }

    /**
     * @param {string} scriptPrefix
     * @param {string} itemClass
     * @param {Object} settingsDefaults
     */
    constructor (scriptPrefix, itemClass, settingsDefaults)
    {
        settingsDefaults.disableItemComplianceValidation = false
        settingsDefaults.showUIAlways = false

        /**
         * Array of item compliance filters ordered in intended sequence of execution
         * @type {Function[]}
         * @protected
         */
        this._complianceFilters = []

        /**
         * @type {string}
         * @protected
         */
        this._itemClass = itemClass

        /**
         * Operations to perform after script initialization
         * @type {Function}
         * @protected
         */
        this._onAfterInitialization = null

        /**
         * Operations to perform after UI generation
         * @type {Function}
         * @protected
         */
        this._onAfterUIBuild = null

        /**
         * Operations to perform before UI generation
         * @type {Function}
         * @protected
         */
        this._onBeforeUIBuild = null

        /**
         * Operations to perform after compliance checks, the first time a item is retrieved
         * @type {Function}
         * @protected
         */
        this._onFirstHitAfterCompliance = null

        /**
         * Operations to perform before compliance checks, the first time a item is retrieved
         * @type {Function}
         * @protected
         */
        this._onFirstHitBeforeCompliance = null

        /**
         * Get item lists from the page
         * @type {Function}
         * @protected
         */
        this._onGetItemLists = null

        /**
         * Logic to hide a non-compliant item
         * @type {Function}
         * @protected
         */
        this._onItemHide = (item) => {item.style.display = 'none'}

        /**
         * Logic to show compliant item
         * @type {Function}
         * @protected
         */
        this._onItemShow = (item) => {item.style.display = 'inline-block'}

        /**
         * Retrieve settings from UI and update settings object
         * @type {Function}
         * @private
         */
        this._onSettingsApply = null

        /**
         * Settings to update in the UI or elsewhere when settings store is updated
         * @type {Function}
         * @protected
         */
        this._onSettingsStoreUpdate = null

        /**
         * Must return the generated settings section node
         * @type {Function}
         * @protected
         */
        this._onUIBuild = null

        /**
         * Validate initiating initialization.
         * Can be used to stop script init on specific pages or vice versa
         * @type {Function}
         * @protected
         */
        this._onValidateInit = () => true

        /**
         * @type {string}
         * @private
         */
        this._scriptPrefix = scriptPrefix

        /**
         * Local storage store with defaults
         * @type {LocalStore}
         * @protected
         */
        this._settingsStore = new LocalStore(this._scriptPrefix + 'settings', settingsDefaults)

        /**
         * @type {Object}
         * @protected
         */
        this._settings = this._settingsStore.retrieve().get()

        /**
         * @type {StatisticsRecorder}
         * @protected
         */
        this._statistics = new StatisticsRecorder(this._scriptPrefix)

        /**
         * @type {UIGenerator}
         * @protected
         */
        this._uiGen = new UIGenerator(this._settings.showUIAlways, this._scriptPrefix)

        /**
         * @type {Validator}
         * @protected
         */
        this._validator = (new Validator(this._statistics))
    }

    /**
     * @param {Function} eventHandler
     * @param {*} parameters
     * @return {null|NodeListOf<HTMLElement>|*}
     * @private
     */
    _callEventHandler (eventHandler, ...parameters)
    {
        if (eventHandler) {
            return eventHandler(...parameters)
        }
        return null
    }

    /**
     * Filters items as per settings
     * @param {HTMLElement|NodeList<HTMLElement>} itemsList
     * @protected
     */
    _complyItemsList (itemsList)
    {
        for (let item of this._getItemsFromItemsList(itemsList)) {

            if (typeof item.scriptProcessedOnce === 'undefined') {
                item.scriptProcessedOnce = false
                this._callEventHandler(this._onFirstHitBeforeCompliance, item)
            }

            this._validateItemCompliance(item)

            if (!item.scriptProcessedOnce) {
                this._callEventHandler(this._onFirstHitAfterCompliance, item)
                item.scriptProcessedOnce = true
            }

            this._statistics.updateUI()
        }
    }

    /**
     * @protected
     */
	 
    _createSettingsFormActions ()
    {
        return this._uiGen.createSettingsFormActions(this._settingsStore, () => {
            this._callEventHandler(this._onSettingsApply)
            this._statistics.reset()
            for (let itemsList of this._callEventHandler(this._onGetItemLists)) {
                this._complyItemsList(itemsList)
            }
        })
    }

    /**
     * @param {HTMLElement|null} UISection
     * @private
     */
    _embedUI (UISection)
    {
        if (UISection) {
            this._uiGen.constructor.appendToBody(UISection)
            this._uiGen.constructor.appendToBody(this._uiGen.createSettingsShowButton('', UISection, true, () => {
                if (!this._settings.showUIAlways) {
                    UISection.style.display = 'none'
                }
            }))
            this._callEventHandler(this._onSettingsStoreUpdate)
        }
    }

    /**
     * @param {HTMLElement|NodeList<HTMLElement>} itemsList
     * @return {NodeListOf<HTMLElement>|HTMLElement[]}
     * @protected
     */
    _getItemsFromItemsList (itemsList)
    {
        let items = []
        if (itemsList instanceof NodeList) {
            itemsList.forEach((node) => {
                if (typeof node.classList !== 'undefined' && node.classList.contains(this._itemClass)) {
                    items.push(node)
                }
            })
        } else {
            items = itemsList.querySelectorAll('.' + this._itemClass)
        }
        return items
    }

    /**
     * @param {Object} sanitizationRules
     * @return {string}
     * @protected
     */
    _transformSanitizationRulesToText (sanitizationRules)
    {
        let sanitizationRulesText = []
        for (let substitute in sanitizationRules) {
            sanitizationRulesText.push(substitute + '=' + sanitizationRules[substitute].join(','))
        }
        return sanitizationRulesText.join('\n')
    }

    /**
     * @param {string[]} strings
     * @protected
     */
    _trimAndKeepNonEmptyStrings (strings)
    {
        let nonEmptyStrings = []
        for (let string of strings) {
            string = string.trim()
            if (string !== '') {
                nonEmptyStrings.push(string)
            }
        }
        return nonEmptyStrings
    }

    /**
     * @param {string[]} blacklistedWords
     * @protected
     */
    _validateAndSetBlacklistedWords (blacklistedWords)
    {
        this._settings.blacklist = this._trimAndKeepNonEmptyStrings(blacklistedWords)
        this._validator.setBlacklist(this._settings.blacklist)
    }

    /**
     * @param {string[]} sanitizationRules
     * @protected
     */
    _validateAndSetSanitizationRules (sanitizationRules)
    {
        let fragments, validatedTargetWords
        this._settings.sanitize = {}

        for (let sanitizationRule of sanitizationRules) {
            if (sanitizationRule.includes('=')) {

                fragments = sanitizationRule.split('=')
                if (fragments[0] === '') {
                    fragments[0] = ' '
                }

                validatedTargetWords = this._trimAndKeepNonEmptyStrings(fragments[1].split(','))
                if (validatedTargetWords.length) {
                    this._settings.sanitize[fragments[0]] = validatedTargetWords
                }
            }
        }
        this._validator.setSanitizationRules(this._settings.sanitize)
    }

    /**
     * @param {HTMLElement|Node} item
     * @protected
     */
    _validateItemCompliance (item)
    {
        let itemComplies = true

        if (!this._settings.disableItemComplianceValidation) {
            for (let complianceFilter of this._complianceFilters) {
                if (!complianceFilter(item)) {
                    itemComplies = false
                    break
                }
            }
        }
        itemComplies ? this._callEventHandler(this._onItemShow, item) : this._callEventHandler(this._onItemHide, item)
    }

    /**
     * Initialize the script and do basic UI removals
     */
    init ()
    {
        try {
            if (this._callEventHandler(this._onValidateInit)) {

                this._callEventHandler(this._onBeforeUIBuild)
                this._embedUI(this._callEventHandler(this._onUIBuild))
                this._callEventHandler(this._onAfterUIBuild)

                for (let itemsList of this._callEventHandler(this._onGetItemLists)) {
                    ChildObserver.create().onNodesAdded((itemsAdded) => this._complyItemsList(itemsAdded)).observe(itemsList)
                    this._complyItemsList(itemsList)
                }

                this._uiGen.updateStatus('Initial run completed.')

                this._callEventHandler(this._onAfterInitialization)

                this._settingsStore.onChange(() => this._callEventHandler(this._onSettingsStoreUpdate))
            }
        } catch (error) {
            console.error(this._scriptPrefix + 'script encountered an error: ' + error)
        }
    }
}