Greasy Fork

Base Brazen Resource

Base library for my scripts

目前为 2020-12-06 提交的版本。查看 最新版本

此脚本不应直接安装,它是一个供其他脚本使用的外部库。如果您需要使用该库,请在脚本元属性加入:// @require https://update.greasyfork.icu/scripts/375557/877530/Base%20Brazen%20Resource.js

// ==UserScript==
// @name         Base Brazen Resource
// @namespace    brazen
// @version      3.2.0
// @author       brazenvoid
// @license      GPL-3.0-only
// @description  Base library for my scripts
// @run-at  	 document-end
// ==/UserScript==

const LINE_BREAK_REGEX = /\r?\n/g

const CONFIG_TYPE_CHECKBOXES_GROUP = 'checkboxes'
const CONFIG_TYPE_FLAG = 'flag'
const CONFIG_TYPE_NUMBER = 'number'
const CONFIG_TYPE_RADIOS_GROUP = 'radios'
const CONFIG_TYPE_RANGE = 'range'
const CONFIG_TYPE_RULESET = 'ruleset'
const CONFIG_TYPE_TEXT = 'text'

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 ConfigurationManager
{
    /**
     * @typedef {{title: string, type: string, element: null|JQuery, value: *, maximum: int, minimum: int, options: string[], helpText: string, onEncode: null|Function, onDecode:
     *     null|Function}} ConfigurationField
     */

    /**
     * @param {BrazenUIGenerator} uiGenerator
     * @return {ConfigurationManager}
     */
    static create (uiGenerator)
    {
        return new ConfigurationManager(uiGenerator)
    }

    constructor (uiGenerator)
    {
        /**
         * @type {{}}
         * @private
         */
        this._config = {}

        /**
         * @type {LocalStore}
         * @private
         */
        this._localStore = null

        /**
         * @type BrazenUIGenerator
         * @private
         */
        this._uiGen = uiGenerator
    }

    /**
     * @param {string} type
     * @param {string} name
     * @param {*} value
     * @param {string} helpText
     * @return ConfigurationField
     * @private
     */
    _createField (type, name, value, helpText)
    {
        let field
        if (typeof this._config[name] === 'undefined') {
            field = {
                element: null,
                helpText: helpText,
                title: name,
                type: type,
                value: value,
            }
            this._config[Utilities.toKebabCase(name)] = field
        } else {
            field = this._config[name]
            field.helpText = helpText
            field.value = value
        }
        return field
    }

    /**
     * @param {boolean} ignoreIfDefaultsSet
     * @private
     */
    _syncLocalStore (ignoreIfDefaultsSet)
    {
        let storeObject = this._localStore.get()
        if (!ignoreIfDefaultsSet || !this._localStore.wereDefaultsSet()) {
            for (let key in this._config) {
                this._config[key].value = storeObject[key]
            }
            this.updateInterface()
        }
        return this
    }

    /**
     * @return {{}}
     * @private
     */
    _toStoreObject ()
    {
        let storeObject = {}
        for (let key in this._config) {
            storeObject[key] = this._config[key].value
        }
        return storeObject
    }

    createElement (name)
    {
        let field = this.getField(name)
        let inputGroup
        switch (field.type) {
            case CONFIG_TYPE_CHECKBOXES_GROUP:
                inputGroup = this._uiGen.createFormCheckBoxesGroupSection(field.title, field.options, field.helpText)
                field.element = inputGroup
                break
            case CONFIG_TYPE_FLAG:
                inputGroup = this._uiGen.createFormInputGroup(field.title, 'checkbox', field.helpText)
                field.element = inputGroup.find('input')
                break
            case CONFIG_TYPE_NUMBER:
                inputGroup = this._uiGen.createFormInputGroup(field.title, 'number', field.helpText).attr('min', field.minimum).attr('max', field.maximum)
                field.element = inputGroup.find('input')
                break
            case CONFIG_TYPE_RADIOS_GROUP:
                inputGroup = this._uiGen.createFormRadiosGroupSection(field.title, field.options, field.helpText)
                field.element = inputGroup
                break
            case CONFIG_TYPE_RANGE:
                inputGroup = this._uiGen.createFormRangeInputGroup(field.title, 'number', field.minimum, field.maximum, field.helpText)
                field.element = inputGroup.find('input')
                break
            case CONFIG_TYPE_RULESET:
                inputGroup = this._uiGen.createFormTextAreaGroup(field.title, 2, field.helpText)
                field.element = inputGroup.find('textarea')
                break
        }
        return inputGroup
    }

    initialize (scriptPrefix)
    {
        this._localStore = new LocalStore(scriptPrefix + 'settings', this._toStoreObject())
        this._localStore.onChange(() => this.updateInterface())

        return this._syncLocalStore(true)
    }

    addCheckboxesGroup (name, options, helpText)
    {
        let field = this._createField(CONFIG_TYPE_CHECKBOXES_GROUP, name, [], helpText)
        field.options = options
        return this
    }

    addFlagField (name, helpText)
    {
        this._createField(CONFIG_TYPE_FLAG, name, false, helpText)
        return this
    }

    addNumberField (name, minimum, maximum, helpText)
    {
        let field = this._createField(CONFIG_TYPE_NUMBER, name, minimum, helpText)
        field.minimum = minimum
        field.maximum = maximum
        return this
    }

    addRadiosGroup (name, options, helpText)
    {
        let field = this._createField(CONFIG_TYPE_RADIOS_GROUP, name, options[0], helpText)
        field.options = options
        return this
    }

    addRangeField (name, minimum, maximum, helpText)
    {
        let field = this._createField(CONFIG_TYPE_RANGE, name, {minimum: minimum, maximum: minimum}, helpText)
        field.minimum = minimum
        field.maximum = maximum
        return this
    }

    addRulesetField (name, helpText, onEncode = null, onDecode = null)
    {
        let field = this._createField(CONFIG_TYPE_RULESET, name, [], helpText)
        field.onEncode = onEncode ?? field.onEncode
        field.onDecode = onDecode ?? field.onDecode
        return this
    }

    addTextField (name, helpText)
    {
        this._createField(CONFIG_TYPE_TEXT, name, '', helpText)
        return this
    }

    /**
     * @param {string} name
     * @return {ConfigurationField}
     */
    getField (name)
    {
        let field = this._config[Utilities.toKebabCase(name)]
        if (field) {
            return field
        }
        throw new Error('Field named "'+ name +'" could not be found')
    }

    getValue (name)
    {
        return this.getField(name).value
    }

    revertChanges ()
    {
        return this._syncLocalStore(false)
    }

    save ()
    {
        this.update()._localStore.save(this._toStoreObject())
        return this
    }

    update ()
    {
        let field, value
        for (let fieldName in this._config) {

            field = this._config[fieldName]
            if (field.element) {

                switch (field.type) {
                    case CONFIG_TYPE_CHECKBOXES_GROUP:
                        field.value = []
                        field.element.find('input:checked').each((index, element) => {
                            field.value.push($(element).attr('data-key'))
                        })
                        break
                    case CONFIG_TYPE_FLAG:
                        field.value = field.element.prop('checked')
                        break
                    case CONFIG_TYPE_NUMBER:
                        field.value = parseInt(field.element.val())
                        break
                    case CONFIG_TYPE_RADIOS_GROUP:
                        field.value = field.element.find('input:checked').attr('data-key')
                        break
                    case CONFIG_TYPE_RANGE:
                        field.value = {
                            minimum: field.element.first().val(),
                            maximum: field.element.last().val(),
                        }
                        break
                    case CONFIG_TYPE_RULESET:
                        value = Utilities.trimAndKeepNonEmptyStrings(field.element.val().split(LINE_BREAK_REGEX))
                        field.value = field.onDecode ? field.onDecode(value, field) : value
                        break
                    default:
                        field.value = field.element.val()
                }
            }
        }
        return this
    }

    updateInterface ()
    {
        let elements, field, value
        for (let fieldName in this._config) {

            field = this._config[fieldName]
            if (field.element) {

                switch (field.type) {
                    case CONFIG_TYPE_CHECKBOXES_GROUP:
                        elements = field.element.find('input')
                        for (let key of field.value) {
                            elements.filter('[data-key="'+ key +'"]').prop('checked', true)
                        }
                        break
                    case CONFIG_TYPE_FLAG:
                        field.element.prop('checked', field.value)
                        break
                    case CONFIG_TYPE_NUMBER:
                        field.element.val(field.value)
                        break
                    case CONFIG_TYPE_RADIOS_GROUP:
                        field.element.find('input[data-key="'+ field.value +'"]').prop('checked', true).trigger('change')
                        break
                    case CONFIG_TYPE_RANGE:
                        field.element.first().val(field.value.minimum)
                        field.element.last().val(field.value.maximum)
                        break
                    case CONFIG_TYPE_RULESET:
                        value = field.onEncode ? field.onEncode(field.value) : field.value
                        field.element.val(value.join('\n'))
                        break
                    default:
                        field.element.val(field.value)
                }
            }
        }
        return this
    }
}

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

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

        /**
         * @type {boolean}
         * @private
         */
        this._defaultsSet = false

        /**
         * @type {string}
         * @private
         */
        this._key = key

        // Events

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

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

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

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

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

    /**
     * @return {*}
     */
    get ()
    {
        this._defaultsSet = false
        let storedStore = window.localStorage.getItem(this._key)
        return storedStore === null ? this.restoreDefaults() : Utilities.objectFromJSON(storedStore)
    }

    importFromFile (file)
    {
    }

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

    /**
     * @return {Object}
     */
    restoreDefaults ()
    {
        this._defaultsSet = true
        this.save(this._defaults)
        return this._defaults
    }

    /**
     * @param {Object} data
     * @return {LocalStore}
     */
    save (data)
    {
        window.localStorage.setItem(this._key, Utilities.objectToJSON(data))
        this._handleOnChange()
        return this
    }

    /**
     * @return {boolean}
     */
    wereDefaultsSet ()
    {
        return this._defaultsSet
    }
}

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(Utilities.toKebabCase(settingName) + '-setting')
    }

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

    /**
     * @param {string} statisticType
     * @return {string}
     */
    getStatLabelSelector (statisticType)
    {
        return this.getSelector(Utilities.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 Utilities
{
    /**
     * @param {string[]} words
     * @return {RegExp}
     */
    static 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} json
     * @return {Object}
     */
    static objectFromJSON (json)
    {
        /** @type {{arrays: Object, objects: Object, properties: Object}} */
        let parsedJSON = JSON.parse(json)
        let arrayObject = {}
        let result = {}

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

            for (let key in arrayObject) {
                result[property].push(arrayObject[key])
            }
        }
        for (let property in parsedJSON.objects) {
            result[property] = Utilities.objectFromJSON(parsedJSON.objects[property])
        }
        for (let property in parsedJSON.properties) {
            result[property] = parsedJSON.properties[property]
        }
        return result
    }

    /**
     * @param {Object} object
     * @return {string}
     */
    static objectToJSON (object)
    {
        let arrayToObject
        let json = {arrays: {}, objects: {}, properties: {}}
        for (let property in object) {
            if (typeof object[property] === 'object') {
                if (Array.isArray(object[property])) {
                    arrayToObject = {}
                    for (let key in object[property]) {
                        arrayToObject[key] = object[property][key]
                    }
                    json.arrays[property] = JSON.stringify(arrayToObject)
                } else {
                    json.objects[property] = Utilities.objectToJSON(object[property])
                }
            } else {
                json.properties[property] = object[property]
            }
        }
        return JSON.stringify(json)
    }

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

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

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

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 {JQuery} textNode
     * @param {Object} rules
     * @return {Validator}
     */
    sanitizeTextNode (textNode, rules)
    {
        textNode.text(this.sanitize(textNode.text(), rules))
        return this
    }

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

    /**
     * @param {string} name
     * @param {JQuery} item
     * @param {string} selector
     * @return {boolean}
     */
    validateNodeExistence (name, item, selector)
    {
        let validationCheck = item.find(selector).length > 0
        this._statisticsRecorder.record(name, validationCheck)

        return validationCheck
    }

    /**
     * @param {string} name
     * @param {JQuery} item
     * @param {string} selector
     * @return {boolean}
     */
    validateNodeNonExistence (name, item, selector)
    {
        let validationCheck = item.find(selector).length === 0
        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
    }
}