Base Brazen Resource

Base library for my scripts

目前為 2020-12-06 提交的版本,檢視 最新版本

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.cn-greasyfork.org/scripts/375557/877475/Base%20Brazen%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 Brazen Resource
// @namespace    brazen
// @version      3.1.1
// @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

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 'flag':
                inputGroup = this._uiGen.createFormInputGroup(field.title, 'checkbox', field.helpText)
                field.element = inputGroup.find('input')
                break
            case '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 'range':
                inputGroup = this._uiGen.createFormRangeInputGroup(field.title, 'number', field.minimum, field.maximum, field.helpText)
                field.element = inputGroup.find('input')
                break
            case 'ruleset':
                inputGroup = this._uiGen.createFormTextAreaGroup(field.title, 2, field.helpText)
                field.element = inputGroup.find('textarea')
                break
            case 'select':
                inputGroup = this._uiGen.createDropDownFormGroup(field.title, field.options, field.helpText)
                field.element = inputGroup.find('select')
                break
        }
        return inputGroup
    }

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

        return this._syncLocalStore(true)
    }

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

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

    addRangeField (name, minimum, maximum, helpText)
    {
        let field = this._createField('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('ruleset', name, [], helpText)
        field.onEncode = onEncode ?? field.onEncode
        field.onDecode = onDecode ?? field.onDecode
        return this
    }

    addSelectField (name, options, helpText)
    {
        let field = this._createField('select', name, '', helpText)
        field.options = options
        return this
    }

    addTextField (name, helpText)
    {
        this._createField('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 'flag':
                        field.value = field.element.prop('checked')
                        break
                    case 'number':
                        field.value = parseInt(field.element.val())
                        break
                    case 'range':
                        field.value = {
                            minimum: field.element.first().val(),
                            maximum: field.element.last().val(),
                        }
                        break
                    case '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 field, value
        for (let fieldName in this._config) {

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

                switch (field.type) {
                    case 'flag':
                        field.element.prop('checked', field.value)
                        break
                    case 'number':
                        field.element.val(field.value)
                        break
                    case 'range':
                        field.element.first().val(field.value.minimum)
                        field.element.last().val(field.value.maximum)
                        break
                    case '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
    }
}