E-Hentai - UX Tweaks

Numerous features to enrich your browsing experience

// ==UserScript==
// @name         E-Hentai - UX Tweaks
// @namespace    brazenvoid
// @version      1.3.6
// @author       brazenvoid
// @license      GPL-3.0-only
// @description  Numerous features to enrich your browsing experience
// @match        https://e-hentai.org/*
// @match        https://exhentai.org/*
// @require      https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js
// @require      https://update.greasyfork.org/scripts/375557/1244990/Base%20Brazen%20Resource.js
// @require      https://update.greasyfork.org/scripts/416104/1371950/Brazen%20UI%20Generator.js
// @require      https://update.greasyfork.org/scripts/418665/1245040/Brazen%20Configuration%20Manager.js
// @require      https://update.greasyfork.org/scripts/429587/1244644/Brazen%20Item%20Attributes%20Resolver.js
// @require      https://update.greasyfork.org/scripts/416105/1300196/Brazen%20Base%20Search%20Enhancer.js
// @grant        GM_addStyle
// @run-at       document-end
// ==/UserScript==

GM_addStyle(
    `#settings-wrapper{min-width:300px;width:300px}.disliked-tag{background-color:lightcoral !important;color:white !important}.disliked-tag:hover{background-color:indianred !important}.disliked-tag > a{color:white !important}.disliked-tag.favourite-tag{background-color:orange !important}.disliked-tag.favourite-tag:hover{background-color:darkorange !important}.favourite-tag{background-color:mediumseagreen !important;color:white !important}.favourite-tag:hover{background-color:forestgreen !important}.favourite-tag > a{color:white !important}`)

const IS_GALLERY_PAGE = $('#gdt').length
const IS_SEARCH_PAGE = $('#f_search').length
const IS_SMALL_WINDOW = $('.stuffbox').length
const IS_WATCHED_PAGE = window.location.pathname === '/watched'

const UI_DEFAULTS_PAGE_RANGE = 'Page Range'
const UI_DEFAULTS_PAGE_RANGE_ENABLE = 'Enable Page Range Filter'
const UI_DEFAULTS_RATING = 'Rating'
const UI_DEFAULTS_RATING_ENABLE = 'Enable Rating Filter'
const UI_DEFAULTS_TAGS = 'Tags'
const UI_DEFAULTS_TAGS_ENABLE = 'Enable Default Tags'

const UI_FAVOURITE_TAGS = 'Favourite Tags'
const UI_DISLIKED_TAGS = 'Disliked Tags'
const UI_VISITED_HIGHLIGHT = 'Highlight Visited'
const UI_AUTO_NEXT_ON_OPEN_ALL_IMAGES = 'Auto Next Page'

class EHentaiSearchAndUITweaks extends BrazenBaseSearchEnhancer
{
    constructor()
    {
        super({
            isUserLoggedIn: false,
            itemDeepAnalysisSelector: '',
            itemLinkSelector: 'td.gl3m.glname > a, td.gl3c.glname > a, div.gl2e > div > a, a',
            itemListSelectors: 'table.itg, div.itg',
            itemNameSelector: 'td.gl3m.glname > a > div.glink, td.gl3c.glname > a > div.glink, div.gl4e.glname > div.glink, div.gl4t.glname.glink',
            itemSelectors: 'td.gl2e, div.gl1t',
            requestDelay: 0,
            scriptPrefix: 'e-hentai-ux-',
            tagSelectorGenerator: (tag) => {
                tag = tag.trim()
                if (IS_GALLERY_PAGE) {
                    return 'div[id="td_' + tag.replace(' ', '_') + '"]'
                }
                return 'div.gt[title="' + tag + '"], div.gtl[title="' + tag + '"]'
            },
        })
        this._setupFeatures()
        this._setupUI()
        this._setupEvents()
    }

    /**
     * @param {string} tag
     * @return {string}
     * @private
     */
    _formatTag(tag)
    {
        if (tag.includes(':') && !tag.includes('"') && (tag.includes(' ') || tag.includes('+'))) {
            tag = tag.replace(':', ':"') + '"'
        }
        return tag
    }

    /**
     * @param {{}} range
     * @param {URLSearchParams} queryParams
     * @private
     */
    _handleDefaultPageRangeFilter(range, queryParams)
    {
        if (range.minimum > 0) {
            queryParams.set('f_spf', range.minimum)
        }
        if (range.maximum > 0) {
            queryParams.set('f_spf', range.maximum)
        }
    }

    /**
     * @param {string} rating
     * @param {URLSearchParams} queryParams
     * @private
     */
    _handleDefaultRatingsFilter(rating, queryParams)
    {
        queryParams.set('f_srdd', rating)
    }

    /**
     * @param {string[]} tags
     * @param {URLSearchParams} queryParams
     * @private
     */
    _handleDefaultTags(tags, queryParams)
    {
        let existingTags = queryParams.get('f_search')
        let updatedTags = existingTags
        let include = true

        for (let tag of tags) {
            if (!existingTags.includes(tag)) {
                updatedTags += '+' + this._formatTag(tag)
            } else {
                include = false
                break
            }
        }

        if (include) {
            queryParams.set('f_search', updatedTags)
        }
    }

    /**
     * @private
     */
    _handleDefaults()
    {
        let queryParams = new URLSearchParams(window.location.search)
        let existingParams = queryParams.toString()

        if (!queryParams.has('next') &&
            (this._getConfig(UI_DEFAULTS_PAGE_RANGE_ENABLE) || this._getConfig(UI_DEFAULTS_RATING_ENABLE) || this._getConfig(UI_DEFAULTS_TAGS_ENABLE))) {

            if (!queryParams.has('f_search')) {
                let existingTag = IS_WATCHED_PAGE ? [] : window.location.pathname.split('/').pop().trim()
                queryParams.set('f_search', existingTag.length ? this._formatTag(existingTag) : '')
            }

            if (!queryParams.has('advsearch')) {
                queryParams.set('advsearch', '1')
            }

            let validatePageRange = (range, defaultValidator) => defaultValidator(range) && !queryParams.has('f_spf') && !queryParams.has('f_spt')

            this._performTogglableComplexOperation(UI_DEFAULTS_PAGE_RANGE_ENABLE, UI_DEFAULTS_PAGE_RANGE, validatePageRange, (range) => {
                this._handleDefaultPageRangeFilter(range, queryParams)
            })

            let validateRatingFilter = (range, defaultValidator) => defaultValidator(range) && !queryParams.has('f_srdd')

            this._performTogglableComplexOperation(UI_DEFAULTS_RATING_ENABLE, UI_DEFAULTS_RATING, validateRatingFilter, (rating) => {
                this._handleDefaultRatingsFilter(rating, queryParams)
            })

            this._performTogglableOperation(UI_DEFAULTS_TAGS_ENABLE, UI_DEFAULTS_TAGS, (tags) => {
                this._handleDefaultTags(tags, queryParams)
            })

            let updatedParams = queryParams.toString().replaceAll('%2B', '+')
            if (updatedParams !== existingParams) {
                if (IS_WATCHED_PAGE) {
                    window.location = window.location.origin + window.location.pathname + '?' + updatedParams
                } else {
                    window.location = window.location.origin + '?' + updatedParams
                }
            }
        }
    }

    /**
     * @private
     */
    _setupEvents()
    {
        this._onValidateInit = () => !IS_SMALL_WINDOW

        this._onBeforeUIBuild.push(() => {
            this._performOperation(UI_VISITED_HIGHLIGHT, () => {
                GM_addStyle(`td.gl2e > div > a:visited > .glname > .glink {color: black;}`)
            })
            if (IS_SEARCH_PAGE) {
                this._handleDefaults()
            }
        })

        this._onAfterUIBuild.push(() => this._uiGen.getSelectedSection()[0].userScript = this)

        this._onItemHide = (item) => {
            if (item.is('td.gl2e')) {
                item.parent().addClass('noncompliant-item')
                item.parent().hide()
            } else {
                item.removeClass('noncompliant-item')
                item.hide()
            }
        }

        this._onItemShow = (item) => {
            if (item.is('td.gl2e')) {
                item.parent().removeClass('noncompliant-item')
                item.parent().show()
            } else {
                item.removeClass('noncompliant-item')
                item.show()
            }
        }
    }

    /**
     * @private
     */
    _setupFeatures()
    {
        this._configurationManager
            .addFlagField(UI_AUTO_NEXT_ON_OPEN_ALL_IMAGES, 'Automatically navigates to the next page after opening all images.')
            .addFlagField(UI_VISITED_HIGHLIGHT, 'Colours the visited gallery links black, to make them distinct.')
            .addRangeField(UI_DEFAULTS_PAGE_RANGE, 0, 2000, 'Enable default page range filter in searches.')
            .addFlagField(UI_DEFAULTS_PAGE_RANGE_ENABLE, 'Always set these page limits in searches. Ignored if you set your own values on the page.')
            .addRadiosGroup(UI_DEFAULTS_RATING, [
                ['2 stars', '2'],
                ['3 stars', '3'],
                ['4 stars', '4'],
                ['5 stars', '5'],
            ], 'Always set this rating filter in searches. Ignored if you set your own value on the page.')
            .addFlagField(UI_DEFAULTS_RATING_ENABLE, 'Enable default rating filter in searches')
            .addRulesetField(UI_DEFAULTS_TAGS, 5, 'Always add the following tags in search. Can be overridden with at least one tag present.')
            .addFlagField(UI_DEFAULTS_TAGS_ENABLE, 'Enable default tags in searches.')

        let otherTagSections = IS_GALLERY_PAGE ? $('#taglist') : null

        this._addItemTagHighlights(UI_FAVOURITE_TAGS, otherTagSections, 'favourite-tag', 'Specify favourite tags to highlight.', 10)
        this._addItemTagHighlights(UI_DISLIKED_TAGS, otherTagSections, 'disliked-tag', 'Specify disliked tags to highlight.', 10)
        this._addItemTagBlacklistFilter(20)
    }

    /**
     * @private
     */
    _setupUI()
    {
        let openGalleryPages = []
        if (IS_GALLERY_PAGE) {
            openGalleryPages = [
                this._uiGen.createSeparator(),
                this._uiGen.createFormButton(
                    'Open Gallery Images',
                    'Opens all images on current page of this gallery.',
                    () => {

                        let images = $('div.gdtl a > img, div.gdtm a > img')
                        let firstPage = images.first().attr('alt')
                        let firstPageNumber = Number(firstPage)
                        let paddedPageLength = firstPage.length
                        let maxPages = firstPageNumber + images.length - 1

                        for (let page = maxPages; page >= firstPageNumber; page--) {

                            let paddedPage = page.toString().padStart(paddedPageLength, '0')
                            window.open(images.filter('[alt="' + paddedPage + '"]').parent().attr('href'))
                        }

                        if (this._getConfig(UI_AUTO_NEXT_ON_OPEN_ALL_IMAGES)) {

                            let page = window.location.href.split('=')[1] || 0
                            let pageNavs = $('.ptt td')
                            let maxPages = Number.parseInt(pageNavs.eq(pageNavs.length - 2).children('a').text()) - 1
                            if (page < maxPages) {

                                let uri = window.location.href
                                if (page === 0) {
                                    uri += '?p=1'
                                } else {
                                    uri = uri.replace('?p=' + page++, '?p=' + page)
                                }
                                window.location = uri
                            }
                        }
                    }),
                this._uiGen.createBreakSeparator(),
                this._configurationManager.createElement(UI_AUTO_NEXT_ON_OPEN_ALL_IMAGES),
            ]
        }

        this._userInterface = [
            this._uiGen.createTabsSection(['Filters', 'Highlights', 'Defaults', 'Global'], [
                this._uiGen.createTabPanel('Filters', true).append([
                    this._configurationManager.createElement(OPTION_ENABLE_TAG_BLACKLIST),
                    this._configurationManager.createElement(FILTER_TAG_BLACKLIST),
                ]),
                this._uiGen.createTabPanel('Highlights').append([
                    this._configurationManager.createElement(UI_FAVOURITE_TAGS),
                    this._configurationManager.createElement(UI_DISLIKED_TAGS),
                ]),
                this._uiGen.createTabPanel('Defaults').append([
                    this._configurationManager.createElement(UI_DEFAULTS_PAGE_RANGE_ENABLE),
                    this._configurationManager.createElement(UI_DEFAULTS_PAGE_RANGE),
                    this._uiGen.createSeparator(),
                    this._configurationManager.createElement(UI_DEFAULTS_RATING),
                    this._uiGen.createBreakSeparator(),
                    this._configurationManager.createElement(UI_DEFAULTS_RATING_ENABLE),
                    this._uiGen.createSeparator(),
                    this._configurationManager.createElement(UI_DEFAULTS_TAGS_ENABLE),
                    this._configurationManager.createElement(UI_DEFAULTS_TAGS),
                ]),
                this._uiGen.createTabPanel('Global').append([
                    this._configurationManager.createElement(UI_VISITED_HIGHLIGHT),
                    this._configurationManager.createElement(OPTION_ALWAYS_SHOW_SETTINGS_PANE),
                    this._uiGen.createSeparator(),
                    this._createSettingsBackupRestoreFormActions(),
                ]),
            ]),
            this._uiGen.createStatisticsFormGroup(FILTER_TAG_BLACKLIST),
            ...openGalleryPages,
            this._uiGen.createSeparator(),
            this._createSettingsFormActions(),
            this._uiGen.createSeparator(),
            this._uiGen.createStatusSection(),
        ]
    }
}

(new EHentaiSearchAndUITweaks).init()