Base Resource

Base library for my scripts

目前為 2020-11-14 提交的版本,檢視 最新版本

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.cn-greasyfork.org/scripts/375557/869002/Base%20Resource.js

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name          Base Resource
// @namespace     brazenvoid
// @version       2.2.1
// @author        brazenvoid
// @license       GPL-3.0-only
// @description   Base library for my scripts
// @require       https://code.jquery.com/jquery-3.5.1.min.js
// @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}select.form-dropdown-multiple{text-align:center;width:100%}textarea.form-input{display:block;height:auto;position:relative;width:98%}`)

/**
 * @param {string[]} words
 * @return {RegExp}
 */
const 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 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])) {
                    arrayToObject = {}
                    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
    }

    /**
     * @param {string} filename
     */
    exportToFile (filename)
    {
        let linkElement = document.createElement('a')
        let file = new Blob([this._toJSON(this._store)], {type: 'application/json'})
        linkElement.href = URL.createObjectURL(file)
        linkElement.download = filename
        linkElement.click()

        URL.revokeObjectURL(linkElement.href)
        linkElement.remove()
    }

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

    importFromFile (file)
    {
    }

    /**
     * @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 = '')
    {
        let button = document.createElement('button')
        button.classList.add('form-button')
        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 {boolean} multiple
     *
     * @return {HTMLSelectElement}
     */
    createFormGroupDropdown (id, keyValuePairs, multiple = false)
    {
        let dropdown = document.createElement('select'), item
        dropdown.id = id

        for (let [key, value] of keyValuePairs) {
            item = document.createElement('option')
            item.textContent = value
            item.value = key
            dropdown.appendChild(item)
        }
        if (multiple) {
            dropdown.classList.add('form-dropdown-multiple')
            dropdown.setAttribute('multiple', '')
            dropdown.querySelector('option[value="' + keyValuePairs[0][0] + '"]').setAttribute('selected', '')
        } else {
            dropdown.classList.add('form-dropdown')
            dropdown.value = keyValuePairs[0][0]
        }

        return dropdown
    }

    /**
     * @param {string} id
     * @param {string} type
     *
     * @return {HTMLInputElement}
     */
    createFormGroupInput (id, type)
    {
        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')
                break

            case 'radio':
            case 'checkbox':
                inputFormGroup.classList.add('check-radio-input')
                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
     * @return {HTMLElement}
     */
    createFormInputGroup (label, inputType = 'text', hoverHelp = '')
    {
        let divFormInputGroup
        let inputID = this._selectorGenerator.getSettingsInputSelector(label)
        let labelFormGroup = this.createFormGroupLabel(label, inputID, inputType)
        let inputFormGroup = this.createFormGroupInput(inputID, inputType)

        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
     *
     * @return {HTMLElement}
     */
    createFormRangeInputGroup (label, inputsType = 'text')
    {
        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),
            this.createFormGroupInput(minInputSelector, inputsType),
        ])
        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 = '')
    {
        let button = this.createFormButton(caption, onClick, hoverHelp)
        button.title = tooltip

        return button
    }

    /**
     * @param {string} label
     * @param {int} rows
     * @param {string} hoverHelp
     * @return {HTMLElement}
     */
    createFormTextAreaGroup (label, rows, hoverHelp = '')
    {
        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.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.'),
                this.createFormButton('Reset', () => {
                    localStore.retrieve()
                    onClick()
                }, 'Restore and apply saved configuration.'),
            ]),
        ])
        if (addTopPadding) {
            divFormActions.style.paddingTop = '10px'
        }
        return divFormActions
    }

    /**
     * @param {string} label
     * @param {Array} keyValuePairs
     * @param {boolean} multiple
     *
     * @return {HTMLElement}
     */
    createSettingsDropDownFormGroup (label, keyValuePairs, multiple = false)
    {
        let dropdownID = this._selectorGenerator.getSettingsInputSelector(label)
        let optionsSelector = '#' + dropdownID + ' option'

        if (multiple) {
            return this.createFormSection(label, [
                this.createFormGroup([
                    this.createFormGroupDropdown(dropdownID, keyValuePairs, multiple),
                ]),
                this.createFormActions([
                    this.createFormButton(
                        'Select All',
                        () => document.querySelectorAll(optionsSelector).forEach((element) => element.setAttribute('selected', true)),
                        'Select all options.',
                    ),
                    this.createFormButton(
                        'Deselect All',
                        () => document.querySelectorAll(optionsSelector).forEach((element) => element.removeAttribute('selected')),
                        'Deselect all options.',
                    ),
                ]),
            ])
        }
        return this.createFormGroup([
            this.createFormGroupLabel(label, dropdownID, 'text'),
            this.createFormGroupDropdown(dropdownID, keyValuePairs, multiple),
        ])
    }

    /**
     * @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
    }

    /**
     * @return {HTMLElement}
     */
    getSelectedSection ()
    {
        return this._section
    }

    /**
     * @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)
    {
        let input = this.getSettingsInput(label)
        if (input.tagName === 'SELECT' && input.hasAttribute('multiple')) {
            return Array.from(input.selectedOptions).map(option => option.value)
        }
        return this.getSettingsInput(label).value
    }

    /**
     * @param {string} label
     *
     * @return {Array}
     */
    getSettingsMultiSelectValues (label)
    {
        return Array.from(this.getSettingsInput(label).selectedOptions).map(option => option.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)
    {
        let select = this.getSettingsInput(label)
        if (Array.isArray(value)) {
            select.querySelectorAll('option').forEach(
                option => value.includes(option.value) ? option.setAttribute('selected', '') : option.removeAttribute('selected'))
        } else {
            select.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 {StatisticsRecorder}
         * @private
         */
        this._statisticsRecorder = statisticsRecorder
    }

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

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

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

    /**
     * @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
    }

    /**
     * @param {string} text
     * @param {Object} rules
     * @param {string} key
     * @return {boolean}
     */
    validateTextContains (text, rules, key)
    {
        let validationCheck = true
        if (rules) {
            this._statisticsRecorder.record(key, validationCheck = text.match(rules) !== null)
        }
        return validationCheck
    }

    /**
     * @param {string} text
     * @param {Object} rules
     * @param {string} key
     * @return {boolean}
     */
    validateTextDoesNotContain (text, rules, key)
    {
        let validationCheck = true
        if (rules) {
            this._statisticsRecorder.record(key, validationCheck = text.match(rules) === null)
        }
        return validationCheck
    }
}

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

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

    /**
     * @param {Object} options
     * @param {string} options.scriptPrefix
     * @param {string[]} options.itemClasses
     * @param {Object} options.paginator
     * @param {boolean} options.paginator.enable
     * @param {string} options.paginator.listSelector
     * @param {string} options.paginator.lastPageUrl
     * @param {Function} options.paginator.getPageNo
     * @param {Function} options.paginator.getPageUrl
     * @param {Function} options.paginator.afterPagination
     * @param {Object} options.configDefaults
     * @param {boolean} options.configDefaults.disableItemComplianceValidation
     * @param {number} options.configDefaults.paginationThreshold
     * @param {boolean} options.configDefaults.showUIAlways
     */
    constructor (options)
    {
        options.configDefaults.disableItemComplianceValidation = false
        options.configDefaults.paginationThreshold = 0
        options.configDefaults.showUIAlways = false

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

        /**
         * @type {string[]}
         * @protected
         */
        this._itemClasses = Array.isArray(options.itemClasses) ? options.itemClasses : [options.itemClasses]

        /**
         * 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 compliance validation. This callback can also be used to skip compliance validation by returning false.
         * @type {null}
         * @protected
         */
        this._onBeforeCompliance = 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

        /**
         * Pagination manager
         * @type {{lastPageUrl: string, listSelector: string, itemsSelector: string, paginatedPageNo: number, enable: boolean, lastPageNo: number, getPageNo: Function, getPageUrl:
         *     Function, currentPageNo: number, afterPagination: Function}}
         * @private
         */
        this._paginator = {
            enable: options.paginator.enable,
            currentPageNo: 0,
            lastPageNo: 0,
            lastPageUrl: options.paginator.lastPageUrl,
            listSelector: options.paginator.listSelector,
            paginatedPageNo: 0,
            afterPagination: options.paginator.afterPagination,
            getPageNo: options.paginator.getPageNo,
            getPageUrl: options.paginator.getPageUrl,
        }

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

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

        /**
         * @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()
        }

        if (this._paginator.enable && this._settings.paginationThreshold) {
            this._paginate()
            if (this._paginator.currentPageNo < this._paginator.paginatedPageNo) {
                this._paginator.afterPagination(this._paginator)
            }
        }
    }

    /**
     * @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') {
                    for (let className of this._itemClasses) {
                        if (node.classList.contains(className)) {
                            items.push(node)
                            break
                        }
                    }
                }
            })
        } else {
            items = itemsList.querySelectorAll('.' + this._itemClasses.join(',.'))
        }
        return items
    }

    /**
     * @private
     */
    _paginate ()
    {
        if (this._paginator.paginatedPageNo < this._paginator.lastPageNo) {
            let compliantItems = $(this._paginator.itemsSelector)

            if (compliantItems.length < this._settings.paginationThreshold) {
                let nextPageUrl = this._paginator.getPageUrl(++this._paginator.paginatedPageNo)
                let sandbox = $('<div id="brazen-sandbox" hidden/>')

                sandbox.appendTo('body')
                sandbox.load(nextPageUrl + ' ' + this._paginator.listSelector, '', () => {
                    sandbox.find('.item.big').insertAfter(compliantItems.last())
                    sandbox.remove()
                    this._paginate()
                })
            }
        }
    }

    /**
     * @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[]} sanitizationRules
     * @protected
     */
    _validateAndSetSanitizationRules (sanitizationRules)
    {
        if (sanitizationRules.length) {
            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
                    }
                }
            }
        }
    }

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

        if (!this._settings.disableItemComplianceValidation && this._callEventHandler(this._onBeforeCompliance, item) !== false) {
            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)) {

                if (this._paginator.enable) {
                    this._paginator.currentPageNo = this._paginator.getPageNo(window.location.href)
                    this._paginator.itemsSelector = this._paginator.listSelector + ' .' + this._itemClasses.join(',.') + ':visible'
                    this._paginator.lastPageNo = this._paginator.getPageNo(this._paginator.lastPageUrl)
                    this._paginator.paginatedPageNo = this._paginator.currentPageNo
                }

                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)
        }
    }
}