Brazen Base Search Enhancer

Base class for search enhancement scripts

Mint 2020.12.20.. Lásd a legutóbbi verzió

Ezt a szkriptet nem ajánlott közvetlenül telepíteni. Ez egy könyvtár más szkriptek számára, amik tartalmazzák a // @require https://update.sleazyfork.org/scripts/416105/882641/Brazen%20Base%20Search%20Enhancer.js hivatkozást.

// ==UserScript==
// @name         Brazen Base Search Enhancer
// @namespace    brazenvoid
// @version      2.3.0
// @author       brazenvoid
// @license      GPL-3.0-only
// @description  Base class for search enhancement scripts
// ==/UserScript==

const CONFIG_PAGINATOR_LIMIT = 'Pagination Limit'
const CONFIG_PAGINATOR_THRESHOLD = 'Pagination Threshold'
const OPTION_ALWAYS_SHOW_SETTINGS_PANE = 'Always Show Settings Pane'
const OPTION_DISABLE_COMPLIANCE_VALIDATION = 'Disable All Filters'

const FILTER_TEXT_BLACKLIST = 'Blacklist'
const FILTER_TEXT_SEARCH = 'Search'
const FILTER_TEXT_SANITIZATION = 'Text Sanitization Rules'
const FILTER_TEXT_WHITELIST = 'Whitelist'

class BrazenPaginator
{
    /**
     * @callback PaginatorAfterPaginationEventHandler
     * @param {BrazenPaginator} paginator
     */

    /**
     * @callback PaginatorGetPageNoFromUrlHandler
     * @param {string} pageUrl
     * @param {BrazenPaginator} paginator
     */

    /**
     * @callback PaginatorGetPageUrlFromPageNoHandler
     * @param {number} pageNo
     * @param {BrazenPaginator} paginator
     */

    /**
     * @callback PaginatorGetPaginationElementForPageNoHandler
     * @param {number} pageNo
     * @param {BrazenPaginator} paginator
     */

    /**
     * @param {JQuery} paginationWrapper
     * @param {JQuery.Selector} listSelector
     * @param {JQuery.Selector} itemClassesSelector
     * @param {string } lastPageUrl
     * @return {BrazenPaginator}
     */
    static create (paginationWrapper, listSelector, itemClassesSelector, lastPageUrl)
    {
        return (new BrazenPaginator).configure(paginationWrapper, listSelector, itemClassesSelector, lastPageUrl)
    }

    /**
     *
     */
    constructor ()
    {
        /**
         * @type {number}
         * @private
         */
        this._currentPageNo = 0

        /**
         * @type {JQuery.Selector}
         * @private
         */
        this._itemClassesSelector = ''

        /**
         * @type {number}
         * @private
         */
        this._lastPageNo = 0

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

        /**
         * @type {JQuery.Selector}
         * @private
         */
        this._listSelector = ''

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

        /**
         * @type {number}
         * @private
         */
        this._paginatedPageNo = 0

        /**
         * @type {JQuery}
         * @private
         */
        this._paginationWrapper = null

        /**
         * @type {JQuery}
         * @private
         */
        this._targetElement = null

        // Events and callbacks

        /**
         * @type {PaginatorAfterPaginationEventHandler}
         * @private
         */
        this._onAfterPagination = null

        /**
         * @type {PaginatorGetPageNoFromUrlHandler}
         * @private
         */
        this._onGetPageNoFromUrl = null

        /**
         * @type {PaginatorGetPageUrlFromPageNoHandler}
         * @private
         */
        this._onGetPageUrlFromPageNo = null

        /**
         * @type {PaginatorGetPaginationElementForPageNoHandler}
         * @private
         */
        this._onGetPaginationElementForPageNo = null
    }

    _conformUIToNewPaginatedState ()
    {
        if (this._pageConcatenated) {
            this._pageConcatenated = false

            let currentPageElement = this.getPaginationElementForPageNo(this._currentPageNo)
            let newSubsequentPageNo = this._paginatedPageNo + 1
            let newSubsequentPageNoUrl = this.getPageUrlFromPageNo(newSubsequentPageNo)

            // Mutate current page no element to show paginated page numbers

            currentPageElement.text(this._currentPageNo + '-' + this._paginatedPageNo)

            // Get next pages' pagination elements

            let currentNextPageElements = currentPageElement.nextAll()

            if (this._paginatedPageNo === this._lastPageNo) {

                // Delete all pagination elements if last page is paginated

                currentNextPageElements.remove()

            } else {

                // Determine whether the paginated page immediately precedes the last page

                if (newSubsequentPageNo !== this._lastPageNo) {

                    // If not so, determine whether pagination element for the page following the paginated page exists

                    let newSubsequentPageElement = this.getPaginationElementForPageNo(newSubsequentPageNo)
                    if (!newSubsequentPageElement.length) {

                        // If it does not exist then try getting the old next page no element

                        let oldSubsequentPageElement = this.getPaginationElementForPageNo(this._currentPageNo + 1)
                        if (oldSubsequentPageElement.length) {

                            // If it does exist then mutate it for this purpose

                            oldSubsequentPageElement.attr('href', newSubsequentPageNoUrl).text(newSubsequentPageNo)

                        } else {

                            // If even that does not exist, then clone the less desirable alternative; the last page element and mutate it to this use

                            let lastPageElement = this.getPaginationElementForPageNo(this._lastPageNo)
                            lastPageElement.clone().insertAfter(currentPageElement).attr('href', newSubsequentPageNoUrl).text(newSubsequentPageNo)

                        }
                    }

                    // Remove any other pagination elements for already paginated pages

                    currentNextPageElements.each((index, element) => {
                        let paginationLink = $(element)
                        if (this.getPageNoFromUrl(paginationLink.attr('href')) <= this._paginatedPageNo) {
                            paginationLink.remove()
                        }
                    })
                }
            }
            Utilities.callEventHandler(this._onAfterPagination, [this])
        }
    }

    /**
     * @param {number} threshold
     * @param {number} limit
     * @private
     */
    _loadAndParseNextPage (threshold, limit)
    {
        let lastPageHasNotBeenReached = this._paginatedPageNo < this._lastPageNo
        let paginationLimitHasNotBeenMet = limit > 0 && (this._paginatedPageNo - this._currentPageNo) < limit
        let compliantItemsAreLessThanTheThreshold = this._targetElement.find(this._itemClassesSelector + ':not(.noncompliant-item)').length < threshold

        if (lastPageHasNotBeenReached && paginationLimitHasNotBeenMet && compliantItemsAreLessThanTheThreshold) {

            this._sandbox.load(this.getPageUrlFromPageNo(++this._paginatedPageNo) + ' ' + this._listSelector, '', () => {
                this._pageConcatenated = true
                this._sandbox.find(this._itemClassesSelector).insertAfter(this._targetElement.find(this._itemClassesSelector + ':last'))
                this._sandbox.empty()
            })
        } else {
            this._conformUIToNewPaginatedState()
        }
    }

    /**
     * @param {JQuery} paginationWrapper
     * @param {JQuery.Selector} listSelector
     * @param {JQuery.Selector} itemClassesSelector
     * @param {string } lastPageUrl
     * @return {BrazenPaginator}
     */
    configure (paginationWrapper, listSelector, itemClassesSelector, lastPageUrl)
    {
        this._lastPageUrl = lastPageUrl
        this._listSelector = listSelector
        this._itemClassesSelector = itemClassesSelector
        this._paginationWrapper = paginationWrapper
        return this
    }

    getCurrentPageNo ()
    {
        return this._currentPageNo
    }

    getLastPageNo ()
    {
        return this._lastPageNo
    }

    getListSelector ()
    {
        return this._listSelector
    }

    /**
     * @param {string} pageUrl
     * @return {number}
     */
    getPageNoFromUrl (pageUrl)
    {
        return Utilities.callEventHandlerOrFail('onGetPageNoFromUrl', this._onGetPageNoFromUrl, [pageUrl, this])
    }

    /**
     * @param {number} pageNo
     * @return {string}
     */
    getPageUrlFromPageNo (pageNo)
    {
        return Utilities.callEventHandlerOrFail('onGetPageUrlFromPageNo', this._onGetPageUrlFromPageNo, [pageNo, this])
    }

    /**
     * @param {number} pageNo
     * @return {JQuery}
     */
    getPaginationElementForPageNo (pageNo)
    {
        return Utilities.callEventHandlerOrFail('onGetPaginationElementForPageNo', this._onGetPaginationElementForPageNo, [pageNo, this])
    }

    getPaginatedPageNo ()
    {
        return this._paginatedPageNo
    }

    getPaginationWrapper ()
    {
        return this._paginationWrapper
    }

    initialize ()
    {
        this._currentPageNo = this.getPageNoFromUrl(window.location.href)
        this._lastPageNo = this.getPageNoFromUrl(this._lastPageUrl)
        this._paginatedPageNo = this._currentPageNo
        this._sandbox = $('<div id="brazen-paginator-sandbox" hidden/>').appendTo('body')
        this._targetElement = $(this._listSelector + ':first')
        return this
    }

    /**
     * @param {PaginatorAfterPaginationEventHandler} handler
     * @return {this}
     */
    onAfterPagination (handler)
    {
        this._onAfterPagination = handler
        return this
    }

    /**
     * @param {PaginatorGetPageNoFromUrlHandler} handler
     * @return {this}
     */
    onGetPageNoFromUrl (handler)
    {
        this._onGetPageNoFromUrl = handler
        return this
    }

    /**
     * @param {PaginatorGetPageUrlFromPageNoHandler} handler
     * @return {this}
     */
    onGetPageUrlFromPageNo (handler)
    {
        this._onGetPageUrlFromPageNo = handler
        return this
    }

    /**
     * @param {PaginatorGetPaginationElementForPageNoHandler} handler
     * @return {this}
     */
    onGetPaginationElementForPageNo (handler)
    {
        this._onGetPaginationElementForPageNo = handler
        return this
    }

    run (threshold, limit)
    {
        if (this._paginationWrapper.length && threshold) {
            this._loadAndParseNextPage(threshold, limit)
        }
        return this
    }
}

class BrazenBaseSearchEnhancer
{
    /**
     * @return BrazenBaseSearchEnhancer
     */
    static initialize ()
    {
        BrazenBaseSearchEnhancer.throwOverrideError()
    }

    static throwOverrideError ()
    {
        throw new Error('Method must be overridden.')
    }

    /**
     * @param {string} scriptPrefix
     * @param {string|string[]} itemClasses
     */
    constructor (scriptPrefix, itemClasses)
    {
        /**
         * Array of item compliance filters ordered in intended sequence of execution
         * @type {Function[]}
         * @protected
         */
        this._complianceFilters = []

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

        /**
         * @type {string}
         * @protected
         */
        this._itemClassesSelector = '.' + this._itemClasses.join(',.')

        /**
         * Pagination manager
         * @type BrazenPaginator|null
         * @protected
         */
        this._paginator = null

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

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

        /**
         * @type {BrazenUIGenerator}
         * @protected
         */
        this._uiGen = new BrazenUIGenerator(this._scriptPrefix)

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

        /**
         * Local storage store with defaults
         * @type {BrazenConfigurationManager}
         * @protected
         */
        this._configurationManager = BrazenConfigurationManager.create(this._uiGen).
            addFlagField(OPTION_DISABLE_COMPLIANCE_VALIDATION, 'Disables all search filters.').
            addFlagField(OPTION_ALWAYS_SHOW_SETTINGS_PANE, 'Always show configuration interface.').
            addNumberField(CONFIG_PAGINATOR_LIMIT, 1, 50, 'Limit paginator to concatenate the specified number of maximum pages.').
            addNumberField(CONFIG_PAGINATOR_THRESHOLD, 1, 1000, 'Make paginator ensure the specified number of minimum results.').
            addRulesetField(FILTER_TEXT_BLACKLIST, '', null, null, (rules) => Utilities.buildWholeWordMatchingRegex(rules)).
            addRulesetField(FILTER_TEXT_SANITIZATION, '', (rules) => {
                let sanitizationRulesText = []
                for (let substitute in rules) {
                    sanitizationRulesText.push(substitute + '=' + rules[substitute].join(','))
                }
                return sanitizationRulesText

            }, (rules) => {
                let sanitizationRules = {}, fragments, validatedTargetWords
                for (let sanitizationRule of rules) {

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

                        validatedTargetWords = Utilities.trimAndKeepNonEmptyStrings(fragments[1].split(','))
                        if (validatedTargetWords.length) {
                            sanitizationRules[fragments[0]] = validatedTargetWords
                        }
                    }
                }
                return sanitizationRules
            }, (rules) => {
                let optimizedRules = {}
                for (const substitute in rules) {
                    optimizedRules[substitute] = Utilities.buildWholeWordMatchingRegex(rules[substitute])
                }
                return optimizedRules
            }).
            addRulesetField(FILTER_TEXT_WHITELIST, '', null, null, (rules) => Utilities.buildWholeWordMatchingRegex(rules)).
            addTextField(FILTER_TEXT_SEARCH, 'Show videos with these comma separated words in their names.')

        // Events

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

        /**
         * @type {Function}
         * @private
         */
        this._onGetItemName = null

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

        /**
         * Logic to show compliant item
         * @type {Function}
         * @protected
         */
        this._onItemShow = (item) => {
            item.removeClass('noncompliant-item')
            item.show()
        }

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

    /**
     * Filters items as per settings
     * @param {JQuery} itemsList
     * @param {boolean} fromObserver
     * @protected
     */
    _complyItemsList (itemsList, fromObserver = false)
    {
        let items = fromObserver ? itemsList.filter(this._itemClassesSelector) : itemsList.find(this._itemClassesSelector)
        items.each((index, element) => {
            let item = $(element)
            if (typeof element['scriptProcessedOnce'] === 'undefined') {
                element.scriptProcessedOnce = false
                element.scriptItemName = Utilities.callEventHandlerOrFail('getItemName', this._onGetItemName, [item])
                Utilities.callEventHandler(this._onFirstHitBeforeCompliance, [item])
            }

            this._validateItemCompliance(item)

            if (!element['scriptProcessedOnce']) {
                Utilities.callEventHandler(this._onFirstHitAfterCompliance, [item])
                element.scriptProcessedOnce = true
            }

            this._statistics.updateUI()
        })
    }

    /**
     * @protected
     */
    _createSettingsFormActions ()
    {
        return this._uiGen.createFormSection().append([
            this._uiGen.createFormActions([
                this._uiGen.createFormButton('Apply', 'Apply settings.', () => this._onApplyNewSettings()),
                this._uiGen.createFormButton('Save', 'Apply and update saved configuration.', () => this._onSaveSettings()),
                this._uiGen.createFormButton('Reset', 'Revert to saved configuration.', () => this._onResetSettings()),
            ]),
        ])
    }

    /**
     * @param {JQuery} UISection
     * @private
     */
    _embedUI (UISection)
    {
        UISection.on('mouseleave', (event) => {
            if (!this._configurationManager.getValue(OPTION_ALWAYS_SHOW_SETTINGS_PANE)) {
                $(event.currentTarget).hide(300)
            }
        })
        if (this._configurationManager.getValue(OPTION_ALWAYS_SHOW_SETTINGS_PANE)) {
            UISection.show()
        }
        this._uiGen.constructor.appendToBody(UISection)
        this._uiGen.constructor.appendToBody(this._uiGen.createSettingsShowButton('', UISection))
    }

    _onApplyNewSettings ()
    {
        this._configurationManager.update()
        this._validateCompliance()
    }

    _onResetSettings ()
    {
        this._configurationManager.revertChanges()
        this._validateCompliance()
    }

    _onSaveSettings ()
    {
        this._onApplyNewSettings()
        this._configurationManager.save()
    }

    /**
     * @protected
     */
    _showNotLoggedInAlert ()
    {
        alert('You need to be logged in to use this functionality')
    }

    /**
     * @param {boolean} firstRun
     * @protected
     */
    _validateCompliance (firstRun = false)
    {
        let itemLists = Utilities.callEventHandler(this._onGetItemLists)
        if (!firstRun) {
            this._statistics.reset()
            itemLists.each((index, itemsList) => {
                this._complyItemsList($(itemsList))
            })
        } else {
            itemLists.each((index, itemList) => {
                let itemListObject = $(itemList)

                if (this._paginator && itemListObject.is(this._paginator.getListSelector())) {
                    ChildObserver.create().onNodesAdded((itemsAdded) => {
                        this._complyItemsList($(itemsAdded), true)
                        this._paginator.run(this._configurationManager.getValue(CONFIG_PAGINATOR_THRESHOLD), this._configurationManager.getValue(CONFIG_PAGINATOR_LIMIT))
                    }).observe(itemList)
                } else {
                    ChildObserver.create().onNodesAdded((itemsAdded) => this._complyItemsList($(itemsAdded), true)).observe(itemList)
                }

                this._complyItemsList(itemListObject)
            })
        }
        if (this._paginator) {
            this._paginator.run(this._configurationManager.getValue(CONFIG_PAGINATOR_THRESHOLD), this._configurationManager.getValue(CONFIG_PAGINATOR_LIMIT))
        }
    }

    /**
     * @param {JQuery} item
     * @return {boolean}
     * @protected
     */
    _validateItemBlacklist (item)
    {
        let field = this._configurationManager.getField(FILTER_TEXT_BLACKLIST)
        return field.value.length ? this._validator.validateTextDoesNotContain(item[0].scriptItemName, field.optimized, FILTER_TEXT_BLACKLIST) : true
    }

    /**
     * @param {JQuery} item
     * @protected
     */
    _validateItemCompliance (item)
    {
        let itemComplies = true

        if (!this._configurationManager.getValue(OPTION_DISABLE_COMPLIANCE_VALIDATION) &&
            this._validateItemWhiteList(item) &&
            Utilities.callEventHandler(this._onBeforeCompliance, [item], true)
        ) {
            for (let complianceFilter of this._complianceFilters) {
                if (!complianceFilter(item)) {
                    itemComplies = false
                    break
                }
            }
        }
        itemComplies ? Utilities.callEventHandler(this._onItemShow, [item]) : Utilities.callEventHandler(this._onItemHide, [item])
    }

    /**
     * @param {JQuery} item
     * @return {boolean}
     * @protected
     */
    _validateItemWhiteList (item)
    {
        let field = this._configurationManager.getField(FILTER_TEXT_WHITELIST)
        return field.value.length ? this._validator.validateTextContains(item[0].scriptItemName, field.optimized, FILTER_TEXT_WHITELIST) : true
    }

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

            this._configurationManager.initialize(this._scriptPrefix)

            if (this._paginator) {
                this._paginator.initialize()
            }

            Utilities.callEventHandler(this._onBeforeUIBuild)
            this._embedUI(Utilities.callEventHandler(this._onUIBuild))
            Utilities.callEventHandler(this._onAfterUIBuild)

            this._configurationManager.updateInterface()

            this._validateCompliance(true)

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

            Utilities.callEventHandler(this._onAfterInitialization)
        }
    }
}