NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name Mayriad's EH Master Script // @namespace https://github.com/Mayriad // @version 2.2.2 // @author Mayriad // @description Adds dozens of features to E-Hentai // @icon https://e-hentai.org/favicon.ico // @updateURL https://openuserjs.org/meta/Mayriad/Mayriads_EH_Master_Script.meta.js // @downloadURL https://openuserjs.org/install/Mayriad/Mayriads_EH_Master_Script.user.js // @supportURL https://github.com/Mayriad/Mayriads-EH-Master-Script // @match https://e-hentai.org/* // @match https://exhentai.org/* // @match https://repo.e-hentai.org/* // @match https://upld.e-hentai.org/* // @match https://forums.e-hentai.org/* // @match https://hentaiverse.org/* // @connect self // @connect e-hentai.org // @connect ehtracker.org // @connect hentaiverse.org // @connect * // @run-at document-start // @grant GM.setValue // @grant GM.getValue // @grant GM.xmlHttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_xmlhttpRequest // @grant GM_download // @copyright 2015-2022, Mayriad (https://github.com/Mayriad) // @license GPL-3.0-or-later; https://www.gnu.org/licenses/gpl-3.0-standalone.html // ==/UserScript== /** * @author Mayriad * @copyright 2015-2022 Mayriad * @license GNU General Public License v3.0 or later * * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public * License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later * version. * * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with this program. If not, see * <https://www.gnu.org/licenses/>. */ // Userscript download: https://openuserjs.org/scripts/Mayriad/Mayriads_EH_Master_Script // GitHub repository: https://github.com/Mayriad/Mayriads-EH-Master-Script // User manual: https://github.com/Mayriad/Mayriads-EH-Master-Script/blob/master/README.md // Support thread: https://forums.e-hentai.org/index.php?showtopic=233955 /* global GM, alert, XPathResult, MutationObserver, DOMParser, Blob */ ;(function () { 'use strict' // JSDoc definitions ------------------------------------------------------------------------------------------------- /** * A callback function that handles a click mouse event. * * @callback clickEventHandler * @param {MouseEvent} [clickEvent] - The event object passed to this event handler on click. */ // Initialisation ---------------------------------------------------------------------------------------------------- // Violentmonkey now also supports GM.* aliases that are compatible with GM API v4, but GM.download() is still not // supported. Therefore, GM.download() can be used to check the userscript engine. const api = { setValue: GM.setValue, getValue: GM.getValue, xmlHttpRequest: GM.xmlHttpRequest, info: GM.info } if (typeof GM.download !== 'undefined') { // Tampermonkey api.download = GM.download api.version = 'v4' } else { // Violentmonkey api.version = 'v3' } // Initialise the settings using the default values below before reading actual settings from the userscript storage. let settings = { applyDarkTheme: { featureEnabled: false }, applyLightTheme: { featureEnabled: false }, relocateMpvThumbnails: { featureEnabled: true }, hideMpvToolbar: { featureEnabled: true }, applyAdditionalFilters: { featureEnabled: false, ratedFilterEnabled: false, ratedFilterStars: '', ratedFilterExceptionEnabled: false, ratedFilterExceptions: 'the favorite list and the popular list', favoritedFilterEnabled: false, favoritedFilterCategories: '', favoritedFilterExceptionEnabled: false, titleFilterEnabled: false, titleFilterType: 'one of the keywords', titleFilterKeywords: '', titleFilterExceptionEnabled: false, titleFilterExceptions: 'the favorite list and the popular list' }, applyTextFilters: { featureEnabled: false, commentatorFilterEnabled: false, commentatorFilterUsernames: '', commentFilterEnabled: false, commentFilterKeywords: '', posterFilterEnabled: false, posterFilterType: 'forum posts', posterFilterUsernames: '', postFilterEnabled: false, postFilterType: 'forum posts', postFilterKeywords: '', spamFilterEnabled: false }, applyDesignFixes: { featureEnabled: true }, improveNavigationBar: { featureEnabled: true, unreadCountsEnabled: true }, addVigilanteLinks: { featureEnabled: true }, showAlternativeRating: { featureEnabled: true, hideStarsEnabled: false }, addGuideLinks: { featureEnabled: true }, applySubjectiveFixes: { featureEnabled: true }, emptyCategoryFilter: { featureEnabled: false }, fitThumbnailTitles: { featureEnabled: true }, colourCodeGalleries: { featureEnabled: true }, fitViewerToScreen: { featureEnabled: true }, fitMpvToScreen: { featureEnabled: true, makeDefaultEnabled: true, mpsModeEnabled: false, seamlessModeEnabled: false }, useAutomatedDownloads: { featureEnabled: false, torrentDownloadEnabled: true, torrentRequirementsEnabled: true, minimumSeedNumber: 3, ignoreRequirementsSize: 2048, personalisedTorrentEnabled: true, apiTorrentDownloadEnabled: true, archiveDownloadEnabled: true, archiveDownloadType: 'original archive', appendIdentifiersEnabled: false, pageDownloadEnabled: true, pageDownloadNumber: 3, pageRangeDownloadEnabled: true, downloadProtectionEnabled: true, hideThumbnailEnabled: true, downloadAlertsEnabled: true }, openGalleriesSeparately: { featureEnabled: true, directMpvEnabled: false }, addJumpButtons: { featureEnabled: true, jumpButtonStyle: 'slide-in rectangular buttons', jumpBehaviourStyle: 'smoothly' }, parseExternalLinks: { featureEnabled: true }, removeMpvTooltips: { featureEnabled: false }, collectDawnReward: { featureEnabled: true }, script: { version: api.info.script.version, filterButtonEnabled: false, firefoxCompatibilityEnabled: false, buttonTooltipEnabled: true } } // These persistent variables are stored and used separately from the settings, and the values below are their default // values. let values = { improveNavigationBar: { lastKarmaRead: '' }, useAutomatedDownloads: { pagesToDownload: {} }, collectDawnReward: { lastCollectedReward: 0 }, script: { version: api.info.script.version } } // These are the possible values for the setting keys that accept pre-defined options, and hence also for the // corresponding option selectors in the control panel. const options = { applyAdditionalFilters: { ratedFilterExceptions: ['the favorite list', 'the popular list', 'the favorite list and the popular list'], titleFilterType: ['one of the keywords', 'one match with the regular expression'], titleFilterExceptions: ['the favorite list', 'the popular list', 'the favorite list and the popular list'] }, applyTextFilters: { posterFilterType: ['forum posts', 'forum threads', 'forum posts and threads'], postFilterType: ['forum posts', 'forum threads', 'forum posts and threads'] }, useAutomatedDownloads: { archiveDownloadType: ['original archive', 'resample archive', 'H@H 780x', 'H@H 980x', 'H@H 1280x', 'H@H 1600x', 'H@H 2400x', 'H@H original'] }, addJumpButtons: { jumpButtonStyle: ['fade-in circular buttons', 'slide-in rectangular buttons'], jumpBehaviourStyle: ['smoothly', 'instantly'] } } // These are the notification messages that will be shown to the user when needed. const messages = { applyAdditionalFilters: { ratedFilterStars: { emptyInputError: 'Since the rated gallery filter has been enabled, please enter the numbers of stars to ' + 'hide the galleries to which you have given these stars.', invalidInputError: 'Invalid input detected. Please enter the numbers of stars in a proper format to hide ' + 'the galleries to which you have given these stars. Please note that the possible numbers are 0.5, 1.0, ' + '1.5, 2.0 ... 4.5, 5.0. You can omit the leading zero in a decimal and the decimal part in an integer, ' + 'but you must separate the numbers by comma.' }, favoritedFilterCategories: { emptyInputError: 'Since the favorited gallery filter has been enabled, please enter the favorite ' + 'categories to hide the galleries you have added to these categories.', invalidInputError: 'Invalid input detected. Please enter the favorite categories in a proper format to ' + 'hide the galleries you have added to these categories. Please note that the names of these categories ' + 'must agree with the actual categories you are using.' }, titleFilterKeywords: { emptyInputError: 'Since the gallery title filter has been enabled, please enter the title keywords to ' + 'hide the galleries that have at least one of these keywords in their displayed titles shown in gallery ' + 'lists.', invalidInputError: 'Since the gallery title filter has been enabled, please enter the regular expression ' + 'to hide the galleries whose title has at least one match with this regular expression.' } }, applyTextFilters: { commentatorFilterUsernames: { emptyInputError: 'Since the gallery commentator filter has been enabled, please enter the usernames of the ' + 'commentators to hide all comments they made.' }, commentFilterKeywords: { emptyInputError: 'Since the gallery comment filter has been enabled, please enter the keywords to hide the ' + 'comments that contain any of these keywords.' }, posterFilterUsernames: { emptyInputError: 'Since the forum poster filter has been enabled, please enter the usernames of the ' + 'posters to hide all posts they made.' }, postFilterKeywords: { emptyInputError: 'Since the gallery comment filter has been enabled, please enter the keywords to hide the ' + 'posts that contain any of these keywords.' } }, useAutomatedDownloads: { minimumSeedNumber: { emptyInputError: 'Since torrent download has been enabled, please enter the minimum number of seeds that ' + 'will be required before a torrent can be considered healthy and viable. Entering 0 would remove this ' + 'minimum seed number requirement.', invalidInputError: 'Invalid input detected. Please note that the setting for the minimum number of seeds ' + 'required only accepts a number between 1 and 9, inclusive.' }, ignoreRequirementsSize: { emptyInputError: 'Since torrent download has been enabled, please enter the gallery size above which the ' + 'torrent requirements do not apply. This should be the largest gallery size that you are willing to ' + 'download as an archive when there is also a torrent available as a fallback option.', invalidInputError: 'Invalid input detected. Please note that the gallery size above which the torrent ' + 'requirements do not apply only accepts a number between 0 and 9999, inclusive. This should be the largest ' + 'gallery size that you are willing to download as an archive when there is also a torrent available as a ' + 'fallback option.' }, pageDownloadNumber: { emptyInputError: 'Since the page download button has been enabled, please enter the number of concurrent ' + 'gallery downloads per tab to be allowed in this mode. A high number might cause temporary bans.', invalidInputError: 'Invalid input detected. Please note that the number of concurrent gallery downloads per ' + 'tab requires a number between 1 and 9, inclusive. A high number might cause temporary bans.' }, runtime: { // Unavailable download errors: networkError: 'A download failed due to a network connection error. Please try again after your network ' + 'recovers', serviceUnavailableError: 'A download failed, because the server was unavailable and the website failed to ' + 'respond. The repair ponies are on the case, so please wait for a few minutes and try again.', backendFetchError: 'A download failed, because the server was unavailable and the website failed to ' + 'respond. Please wait for a few hours and try again.', unavailableArchiverError: 'An archive download failed, because the archiver for this gallery is unavailable ' + 'at the moment. Please wait for a few hours and try again.', unavailableTorrentError: 'A torrent download failed, because the torrent is not available at the moment. ' + 'Please try again after a few minutes or manually download the personalised torrent instead.', // Failed download errors: unavailableGalleryError: 'A download failed, because the gallery is removed or not available.', downloadedBytesError: 'An archive download failed, because you have clocked too many downloaded bytes on ' + 'this archive link and it is no longer usable. You can wait for it to expire after a few days and then buy ' + 'the archive again, or manually cancel the current link in the archive selection popup and buy the archive ' + 'again immediately.', expiredSessionError: 'An archive download failed, because you purchased this archive a week ago and the ' + 'expiry of that session stopped the current download. Please try again after one day.', illegalFilenameError: 'A download failed, because the name of the file being downloaded contains ' + 'one or more illegal characters not accepted by GM.download(). Please manually download this gallery.', // Temporary ban errors: heavyLoadError: 'A download is stopped, because you have been warned by the site for loading too many pages ' + 'and/or images too quickly. Please slow down and wait for a while before continuing with the download.', temporaryBanError: 'A download failed, because you have been temporarily banned from EH for loading too many' + 'pages and/or images too quickly. Please wait until the ban is lifted.', // Setup errors: notLoggedInError: 'An archive/H@H download failed, because you are not logged in. Please log in first ' + 'before attempting an automated archive/H@H download.', autoSelectHathError: 'A H@H download failed, because you have selected to use the H@H downloader, but you ' + 'are using an archiver setting that auto-selects the doggie bag archive to download in your EH gallery ' + 'settings. Please note that you can only use the master script to download via the H@H downloader if your ' + 'archiver settings is on "manual select"', unqualifiedHathError: 'A H@H download failed, because you have selected to use the H@H downloader, but you ' + 'do not qualify for this downloader. Please note that you will only be entitled to use this downloader if ' + 'you are running a H@H client.', gmDownloadFileExtensionError: 'An file download failed, because the extension of this file is not in the ' + 'whitelist for GM.download() in Tampermonkey advanced settings. This means you have either not added ' + '.torrent to this list or removed .zip from it. Please ensure both extensions are whitelisted in the ' + '"downloads beta" section in Tampermonkey advanced settings.', gmDownloadNotEnabledError: 'A download attempt failed, because the GM.download() function is not enabled ' + 'or does not have permission. Please check the "downloads beta" section in your Tampermonkey settings.', gmDownloadNotSupportedError: 'A download attempt failed, because the GM.download() function is not ' + 'supported by your userscript engine or browser. Please note that this download method is only supported ' + 'by Tampermonkey running on certain browsers.', crossOriginNotAllowedError: 'A download attempt failed, because this script is not allowed to access ' + 'cross-origin archive servers. Archives are served from random cross-origin servers, so this script needs ' + 'to be granted access to all domains at all times in Tampermonkay.', unknownError: 'The download cannot be initiated for some unknown reason.' } } } // These are common variables used in feature functions. const windowUrl = window.location.href // Most page types can be determined from URL but not all, and the display mode can only be determined from the // "interactive" ready state onwards in all gallery lists except for gallery toplists, so these variables will not be // used in functions that run at the "loading" ready state. let pageType let displayMode /** * Reads settings and values from storage and runs the script. */ ;(async function () { /** * Loads saved data from the userscript storage and decides whether default, saved, or updated data should be used. * * @param {string} savedDataName - The name to be used by GM.getValue() to retrive the saved data from storage. * @param {Object} defaultData - A hard-coded object literal with default values from the start of the script. * @returns {Object} An object literal that could be the saved data, updated saved data, or default data. */ const loadData = async function (savedDataName, defaultData) { let savedData = await api.getValue(savedDataName) if (typeof savedData === 'undefined') { // Use the default data when nothing has been saved. return defaultData } savedData = JSON.parse(savedData) if (savedData.script.version === defaultData.script.version) { // Use the saved data directly when the version and hence the script have not changed. return savedData } else { // Update the keys of the saved data after a script update and use the updated data. const updatedData = updateKeys(renameKeys(savedData, 2), defaultData, 2) // Update the userscript storage as well so that the data do not need to be updated everytime this script runs. updatedData.script.version = defaultData.script.version api.setValue(savedDataName, JSON.stringify(updatedData)) return updatedData } } /** * Renames keys of an object literal while keeping their values by creating new properties and removing old ones. * * It is easier to do this task in a separate function that runs before updateData(). This function always runs in * loadData() but does nothing when the rename list is empty. * * @param {Object} data - An object literal whose keys will be renamed when needed. * @param {number} levelsToCheck - An integer n, which means the function will check up to keys on the nth level. * @returns {Object} An object literal whose keys have been renamed where necessary. */ const renameKeys = function (data, levelsToCheck) { // This object should contain "old name": "new name" pairs. const renames = { useDownloadShortcuts: 'useAutomatedDownloads' } // Make no change and directly return the same object when nothing needs to be renamed. if (Object.keys(renames).length === 0) { return data } for (const oldName of Object.keys(renames)) { for (const key of Object.keys(data)) { // Try to find and rename the target property on one level in one branch, and use recursion where applicable. // The search is rather thorough because it assumes the same property can exist on multiple levels in multiple // branches. if (key === oldName) { const newName = renames[oldName] data[newName] = data[oldName] delete data[oldName] if (levelsToCheck > 1) { data[newName] = renameKeys(data[newName], levelsToCheck - 1) } // Break the loop since the same key cannot exist twice on one level in one branch. break } else if (levelsToCheck > 1) { data[key] = renameKeys(data[key], levelsToCheck - 1) } } } return data } /** * Checks a previously saved data object against a default one recursively to update its keys after a script update. * * @param {Object} savedData - A previously saved object literal read from the userscript storage. * @param {Object} defaultData - A hard-coded object literal with default values from the start of the script. * @param {number} levelsToCheck - An integer n, which means the function will check up to keys on the nth level. * @returns {Object} An object literal whose keys and values are adjusted to match the defaults where necessary. */ const updateKeys = function (savedData, defaultData, levelsToCheck) { const keyUnion = [...new Set([...Object.keys(savedData), ...Object.keys(defaultData)])] for (const key of keyUnion) { if (typeof savedData[key] === 'undefined') { // Check for newly added keys that only exist in the default data. The default values will be added to the // saved data. savedData[key] = defaultData[key] } else if (typeof defaultData[key] === 'undefined') { // Check for removed keys that no longer exist in the default data. These keys will be removed from the saved // data. delete savedData[key] } else { if (levelsToCheck > 1) { // Check the keys under this key when this key is consistent between the two data objects. savedData[key] = updateKeys(savedData[key], defaultData[key], levelsToCheck - 1) } } } return savedData } // Load settings and values from storage before running feature functions. settings = await loadData('settings', settings) values = await loadData('values', values) // Run feature functions at two different ready states. runFeaturesAtLoading() scheduleForInteractive(runFeaturesAtInteractive) })() // Feature functions ------------------------------------------------------------------------------------------------- /** * Runs feature functions at the "loading" ready state, which belong to feature function group 1. */ const runFeaturesAtLoading = function () { // Function that are supposed to run at this ready state can often fail to run on Firefox. If the Firefox // compatibility mode is enabled, then they will be loaded at the "interactive" ready state to ensure they will // always be able to run, at the cost of causing noticeable visual changes when they are loaded. if (!settings.script.firefoxCompatibilityEnabled) { // Group 1: functions that can run before DOM elements are loaded. settings.applyDarkTheme.featureEnabled && applyDarkTheme() settings.applyLightTheme.featureEnabled && applyLightTheme() settings.relocateMpvThumbnails.featureEnabled && relocateMpvThumbnails() settings.hideMpvToolbar.featureEnabled && hideMpvToolbar() } } /** * Applies a full, scientific dark theme to the entire gallery system on the applicable domain. * * This theme is mainly scientifically produced by summarising colour differences between style sheets. Custom styles * are added to cover unique pages, inline styles and style tags from document.head. */ const applyDarkTheme = function () { // This feature is only applicable to EH, and it covers the entire EH. if (!windowUrl.includes('e-hentai.org') || windowUrl.includes('forums.e-hentai.org')) { return } // These are the colour differences programmatically extracted from two 0360 style sheets. They do not cover // everything because there are unique pages, inline styles and head styles. let scientificDarkStyles = ` body { color: #f1f1f1; background: #34353b } a { color: #DDDDDD } a:hover { color: #EEEEEE } /* input */ input, select, option, optgroup, textarea { color: #f1f1f1; background-color: #34353b } input[type = "button"], input[type = "submit"] { border: 2px solid #8d8d8d } select { border: 2px solid #8d8d8d } input[type = "button"]:enabled:hover, input[type = "submit"]:enabled:hover, select:enabled:hover, input[type = "button"]:enabled:focus, input[type = "submit"]:enabled:focus, select:enabled:focus { background-color: #43464e !important; border-color: #aeaeae !important } input[type = "button"]:enabled:active, input[type = "submit"]:enabled:active { background: radial-gradient(#1a1a1a, #43464e) !important; border-color: #c3c3c3 !important } input[type = "text"], input[type = "date"], input[type = "password"], textarea { border: 2px solid #8d8d8d } input:disabled, select:disabled, textarea:disabled { color: #8a8a8a; -webkit-text-fill-color: #8a8a8a } input::placeholder, textarea::placeholder { color: #8a8a8a; -webkit-text-fill-color: #8a8a8a } input[type = "text"]:enabled:hover, input[type = "date"]:enabled:hover, input[type = "password"]:enabled:hover, textarea:enabled:hover, input[type = "text"]:enabled:focus, input[type = "date"]:enabled:focus, input[type = "password"]:enabled:focus, textarea:enabled:focus { background-color: #43464e } input[type = "file"] { border: 2px solid #8d8d8d } .lc:hover input:enabled ~ span, .lr:hover input:enabled ~ span, .lc input:enabled:focus ~ span, .lr input:enabled:focus ~ span { background-color: #43464e !important; border-color: #aeaeae !important } .lc input:disabled ~ span, .lr input:disabled ~ span { border-color: #5c5c5c !important } .lc > span { background-color: #34353b; border: 2px solid #8d8d8d } .lc > span:after { border: solid #f1f1f1 } .lr > span { background-color: #34353b; border: 2px solid #8d8d8d } .lr > span:after { background: #f1f1f1 } /* misc */ .br { color: #FF3333 } .stuffbox { background: #4f535b; border: 1px solid #000000 } /* rating */ img.th { border: 1px solid #000000 } div.ido { background: #4f535b; border: 1px solid #000000 } /* index search/navigation */ .searchwarn { color: #FB7878 } .searchnav div > span { color: #777 } /* shared table stuff */ div.itg { border-top: 2px ridge #3c3c3c; border-bottom: 2px ridge #3c3c3c } table.itg { border: 2px ridge #3c3c3c } table.itg > tbody > tr > th { background: #40454b } table.itg > tbody > tr:nth-child(2n + 1), table.itg > tbody > tr:nth-child(2n + 1) .glthumb, table.itg > tbody > tr:nth-child(2n + 1) .glcut { background: #363940 } table.itg > tbody > tr:nth-child(2n + 2), table.itg > tbody > tr:nth-child(2n + 2) .glthumb, table.itg > tbody > tr:nth-child(2n + 2) .glcut { background: #3c414b } table.mt { border: 1px solid #000000; background: #40454b } table.mt > tbody > tr:nth-child(2n + 1) { background: #363940 } table.mt > tbody > tr:nth-child(2n + 2) { background: #3c414b } tr.gtr, table.mt > tbody > tr:first-child { background: #40454b !important } td.itd { border-right: 1px solid #6f6f6f4d } /* login boxes */ div.d { border: 1px solid #000000; background: #4f535b } div.ds { border: 1px solid #000000; background: #4f535b } /* index */ div.idi { border: 2px ridge #3c3c3c } /* gallery list */ a:visited .glink, a:active .glink { color: #BBBBBB } a:hover .glink { color: #EEEEEE } .glname a :not(.glink), a .glname :not(.glink) { color: #dddddd } .glcat { border-right: 1px solid #6f6f6f4d } .glthumb { border: 2px solid #6f6f6f4d } .glthumb > div:nth-child(1) { border: 1px solid #000000 } .gltc > tbody > tr > td, .glte > tbody > tr > td { border-right: 1px solid #6f6f6f4d } .gltm > tbody > tr > td { border-right: 1px solid #6f6f6f4d } .gl1c, .gl2c, .gl3c, .gl4c, .glfc { border-top: 1px solid #6f6f6f4d; border-bottom: 1px solid #6f6f6f4d } .gl1e, .gl2e, .glfe { border-top: 1px solid #6f6f6f4d; border-bottom: 1px solid #6f6f6f4d } .gl1e > div { border: 1px solid #000000 } .gl4e { border-left: 1px solid #6f6f6f4d } .gld { border-left: 1px solid #6f6f6f4d } .gl1t { border-right: 1px solid #6f6f6f4d; border-bottom: 1px solid #6f6f6f4d } .gl3t { border: 1px solid #000000 } .gl1t:nth-child(2n + 1) { background: #363940 } .gl1t:nth-child(2n + 2) { background: #3c414b } /* category buttons */ .ct1 { background: #777777; border-color: #777777 } /* misc */ .ct2 { background: #9E2720; border-color: #9E2720 } /* doujinshi */ .ct3 { background: #DB6C24; border-color: #DB6C24 } /* manga */ .ct4 { background: #D38F1D; border-color: #D38F1D } /* artistcg */ .ct5 { background: #6A936D; border-color: #617C63 } /* gamecg */ .ct6 { background: #325CA2; border-color: #325CA2 } /* imageset*/ .ct7 { background: #6A32A2; border-color: #6A32A2 } /* cosplay */ .ct8 { background: #A23282; border-color: #A23282 } /* asianporn */ .ct9 { background: #5FA9CF; border-color: #5FA9CF } /* nonh */ .cta { background: #AB9F60; border-color: #AB9F60 } /* western */ /* page selector */ table.ptt { color: #f1f1f1 } table.ptt td { background: #34353b; border: 1px solid #000000 } table.ptt td:hover { color: #000000; background: #43464e } table.ptb { color: #f1f1f1 } table.ptb td { background: #34353b; border: 1px solid #000000 } table.ptb td:hover { color: #000000; background: #43464e } td.ptds { color: #000000 !important; background: #43464e !important } td.ptdd:hover { color: #C2A8A4 !important; background: #34353b !important } /* gallery */ a.tup { color: #00E639 } a.tdn { color: #FF3333 } span.tup { color: #00E639 } span.tdn { color: #FF3333 } div.gm { background: #4f535b; border: 1px solid #000000 } div#gmid { background: #4f535b } div#gd1 div { border: 1px solid #000000 } div#gd2 { background: #4f535b } h1#gj { color: #b8b8b8; border-bottom: 1px solid #000000 } div#gd4 { border-left: 1px solid #000000; border-right: 1px solid #000000 } div#gdt { background: #4f535b; border: 1px solid #000000 } div#gdt img { border: 1px solid #000000 } .g3 a { color: #FF4A4A } div.gt { border: 1px solid #989898; background: #4f535b } div.gtl { border: 1px dashed #8c8c8c; background: #4f535b } div.gtw { border: 1px dotted #8c8c8c; background: #4f535b } #gds { background: #4f535b } #grl { color: #FF3333 } div.c2 { background: #34353b; border: 1px solid #4f535b } div.ths { border: 1px solid #989898; background: #4f535b } div.tha { border: 1px solid #706563 } div.tha:hover { background: #4f535b; color: #000000 } div.thd { border: 1px solid #706563; color: #706563 } /* image pages */ div.sni { background: #4f535b; border: 1px solid #000000 }` // These are extracted from the style tags in document.head, including the first checkbox fix. scientificDarkStyles += ` /* keep the ticks in checkboxes */ .lc > span:after { border-width: 0 3px 3px 0 !important; } /* page-specific */` if (/e-hentai\.org\/g\/\d+\/[0-9a-z]+\/\?act=expunge/.test(windowUrl)) { scientificDarkStyles += ` #gdt.exp_outer { border-color: #000000; } .exp_entry { border-color: #8d8d8d; } .exp_table { border-color: #34353b; }` } else if (/e-hentai\.org\/mpv\//.test(windowUrl)) { scientificDarkStyles += ` div.mi0 { background: #43464e; border: 1px solid #34353b; }` } else if (windowUrl.includes('favorites.php')) { scientificDarkStyles += ` div.fp:hover { background:#43464e; } div.fps { background:#43464e; } @supports (display:grid) { @media screen and (max-width:1080px) { .gl1t:nth-child(8n + 1), .gl1t:nth-child(8n + 3), .gl1t:nth-child(8n + 6), .gl1t:nth-child(8n + 8) { background: #363940; } .gl1t:nth-child(8n + 2), .gl1t:nth-child(8n + 4), .gl1t:nth-child(8n + 5), .gl1t:nth-child(8n + 7) { background: #3c414b; } } }` } else if (windowUrl.includes('mytags')) { scientificDarkStyles += ` #usertags_mass > div { border-top: 1px solid #34353b; } .jscolor { border: 1px solid #8d8d8d; } .tagcomplete-items { border: 1px solid #8d8d8d; } .tagcomplete-items div { background-color: #4f535b; } .tagcomplete-items div:not(:last-child) { border-bottom: 1px solid #5e5e5e; } .tagcomplete-items div:last-child { border-bottom: 1px solid #8d8d8d; } .tagcomplete-items div:hover { background-color: #43464e; } .tagcomplete-active { background-color: #f1f1f1 !important; color: #4f535b }` } else if (windowUrl.includes('managegallery')) { scientificDarkStyles += ` td.l { border-bottom: 1px solid #f1f1f1; border-right: 1px dashed #f1f1f1; } td.r { border-bottom: 1px solid #f1f1f1; } td#d { border-right: 1px dashed #f1f1f1; } div[id ^= "cell_"] { background: #5f636b; border: 1px solid #34353b; }` // The upload list also has styles in document.head, but they are the same on both sides. There are only two // effective colour properties, but they only fit the light theme, so a fix is added to the design fixes feature // for the dark theme. } else if (windowUrl.includes('bounty.php?bid=')) { scientificDarkStyles += ` span.scr { color: red; } span.scb { color: blue; } span.scg { color: green; } span.sco { color: #FF8C00; } div#x { border-color: #34353b; background: #43464e; } div#g th { border-bottom-color: #000000; } div#h th { border-bottom-color: #000000; }` } else if (windowUrl.includes('bounty.php?act=top')) { scientificDarkStyles += ` span.scr { color: red; } span.scb { color: blue; } span.scg { color: green; } span.sco { color: #FF8C00; } div#t img { border-color: black; } div#f span { color: #f1f1f1; } div.d4 { border-color: #000000; } div.d5 { border-color: #000000; }` } else if (windowUrl.includes('gallerytorrents.php')) { scientificDarkStyles += ` table#ett { background: #43464e; border: 1px solid #34353b; } div#etd { background: #43464e; border: 1px solid #34353b; }` } else if (windowUrl.includes('archiver.php')) { scientificDarkStyles += ` div#db { border: 1px solid #000000; background: #4f535b; }` } // These styles cover the styles not included in the default dark styles in style sheet and style tags, so they // cannot be scientifically produced. Some of them also override the scientific styles. let customDarkStyles = ` /* cover event pane */ #eventpane { background: #4f535b !important; border-color: #000000 !important; } /* use consistent round cornors */ div.ido, .stuffbox { border-radius: 9px; }` if (/e-hentai\.org\/g\/\d+\/[0-9a-z]+/.test(windowUrl)) { // The first two rules replicate the default dark style. The last rule below targets the content warning div when // it is there; otherwise it targets the eventpane or .gm, but it will not have an effect beacuse these elements // already use this background colour. customDarkStyles += ` div#tagpopup { background: #4f535b; border: 1px solid #000000 } div#tagpopup h2:hover { color: #ffffff } img.ygm { filter: brightness(100); } #nb + div { background: #4f535b !important }` } else if (/e-hentai\.org\/mpv\//.test(windowUrl)) { customDarkStyles += ` div.mi2, div.mi3, div#bar3 img { filter: invert(0.8); }` } else if (windowUrl.includes('gallerytorrents.php')) { customDarkStyles += ` #torrentinfo > div + div { border-top-color: #000000 !important; }` } else if (windowUrl.includes('archiver.php')) { scientificDarkStyles += ` #hathdl_form + table td { border-color: #f1f1f1 !important; }` } else if (windowUrl.includes('home.php')) { customDarkStyles += ` div.homebox { border-color: #000000; } div.homebox td { border-right-color: #000000 !important; }` } else if (windowUrl.includes('stats.php')) { customDarkStyles += ` .stuffbox table { background: #4f535b !important; border-color: #000000 !important; } tr > td.stdk, tr > td.stdv { border-color: #000000; }` if (windowUrl.includes('gid')) { // For the gallery ranking table on the EH-only public gallery statistics page: customDarkStyles += ` table th { border-bottom-color: #000000 !important; }` } } else if (windowUrl.includes('bitcoin.php')) { customDarkStyles += ` #coinselector > div[onclick] { background-color: #4f535b; } #coinselector > div[onclick]:hover { background-color: #43464e; } #coinselector > div[onclick]:hover > a { color: #ffffff; } #douter > #coinselector > div { border-color: #000000; } #adon { border-top-color: #000000; } #adon > div:nth-child(3) { border-left-color: #000000; } #tdon th { border-bottom-color: #000000; } #dlvl { background-color: #34353b; } #houter { border-top-color: #000000; } #houter > div:nth-child(2) { border-left-color: #000000; }` } else if (windowUrl.includes('exchange.php')) { customDarkStyles += ` .stuffbox h2 { border-bottom-color: #000000; }` } else if (windowUrl.includes('logs.php?t=credits')) { customDarkStyles += ` #lb + div { border-radius: 9px; background: #4f535b !important; border-color: #000000 !important; } #lb + div th { border-bottom-color: #000000 !important; };` } else if (windowUrl.includes('logs.php?t=karma')) { customDarkStyles += ` #lb + div + div { border-radius: 9px; background: #4f535b !important; border-color: #000000 !important; } #lb + div + div th { border-bottom-color: #000000 !important; };` } else if (windowUrl === 'https://e-hentai.org/bounty.php') { // Colour the page number arrow when it is not clickable. customDarkStyles += ` td.ptdd, td.ptdd:hover { color: #73767c !important; }` } else if (windowUrl.includes('bounty.php?act=top')) { // Colour the arrow for the previous page when it is not clickable on the three bounty toplists. The page number // has no limit so only one arrow needs a fix. customDarkStyles += ` div#p > span { color: #73767c; }` } else if (windowUrl.includes('bounty.php?bid=')) { // Fix the colour of the PM icon. customDarkStyles += ` img.ygm { filter: brightness(100); }` } else if (windowUrl.includes('bounty_post.php')) { customDarkStyles += ` div.d4, div.d5 { border-color: #000000; } #b.stuffbox td.l, #b.stuffbox td.r { border-bottom-color: #000000; }` } else if (windowUrl.includes('news.php')) { customDarkStyles += ` .nwo h2, .nwo .newstitle { border-bottom-color: #000000; }` } else if (windowUrl.includes('karma.php')) { customDarkStyles += ` body > div:first-child { border-radius: 9px; background: #4f535b !important; border-color: #000000 !important; } #as { padding-bottom: 0 !important; background: #4f535b !important; border-color: #aeaeae !important; }` } else if (windowUrl.includes('tools.php?act=track_expunge')) { // Using the brightness filter seems to disfigure the text in anchor elements on Firefox, so a more hardcoded // approach is used below. It is likely caused by bitmap conversion of ClearType text. Revoked expunge petitions // will still appear disfigured because they cannot be identified via CSS. If Firefox fixes this problem with // ClearType, then a simple filter will be enough. customDarkStyles += ` /* cover everything but avoid application to usernames */ td:not(:nth-child(3)) { filter: brightness(2); } /* avoid application to conflict gallery */ body > div > div > table > tbody > tr:nth-child(4) > td:nth-child(2), /* avoid application to entire tables */ body > div > div > table > tbody > tr:nth-child(5) > td:nth-child(2), body > div > div > table > tbody > tr:nth-child(8) > td:nth-child(2) { filter: none; }` } else if (windowUrl.includes('tools.php?act=track_rename')) { customDarkStyles += ` /* cover submitted rename titles */ body > div > div > div > div:nth-child(1), /* cover the vote details but avoid application to current and original titles and usernames */ body > div > div:nth-child(3) td:not(:nth-child(3)), body > div > div:nth-child(5) td:not(:nth-child(3)) { filter: brightness(2); }` } const scientificDarkStylesElement = appendStyleText(document.documentElement, 'scientificDarkStyles', scientificDarkStyles) appendStyleText(document.documentElement, 'customDarkStyles', customDarkStyles) /** * Appends the styles whose applicability can only be determined at the "interactive" ready state. */ const addStylesAtInteractive = function () { // The existing "displayMode" variable is not used due to its asynchronous assignment. const displayMode = document.querySelector('.searchnav option[selected = "selected"]') if (displayMode !== null && displayMode.textContent.toLowerCase() === 'thumbnail') { // These are extracted from the style tag in document.head of the search index in the thumbnail display mode. scientificDarkStylesElement.textContent += ` @supports(display:grid) { @media screen and (max-width:1080px) { .gl1t:nth-child(8n + 1), .gl1t:nth-child(8n + 3), .gl1t:nth-child(8n + 6), .gl1t:nth-child(8n + 8) { background: #363940; } .gl1t:nth-child(8n + 2), .gl1t:nth-child(8n + 4), .gl1t:nth-child(8n + 5), .gl1t:nth-child(8n + 7) { background: #3c414b; } } @media screen and (max-width:860px) { .gl1t:nth-child(2n + 1) { background: #363940; } .gl1t:nth-child(2n + 2) { background: #3c414b; } } }` } } scheduleForInteractive(addStylesAtInteractive) } /** * Applies a full, scientific light theme to the entire gallery system on the applicable domain. * * This theme is mainly scientifically produced by summarising colour differences between style sheets. Custom styles * are added to cover inline styles and style tags from document.head. */ const applyLightTheme = function () { // This feature is only applicable to EX, and it covers the entire EX. if (!windowUrl.includes('exhentai.org')) { return } // These are the colour differences programmatically extracted from two 0360 style sheets. They do not cover // everything because there are inline styles and head styles. let scientificLightStyles = ` body { color: #5C0D11; background: #E3E0D1 } a { color: #5C0D11 } a:hover { color: #8F4701 } /* input */ input, select, option, optgroup, textarea { color: #5C0D12; background-color: #EDEADA } input[type = "button"], input[type = "submit"] { border: 2px solid #B5A4A4 } select { border: 2px solid #B5A4A4 } input[type = "button"]:enabled:hover, input[type = "submit"]:enabled:hover, select:enabled:hover, input[type = "button"]:enabled:focus, input[type = "submit"]:enabled:focus, select:enabled:focus { background-color: #F3F0E0 !important; border-color: #977273 !important } input[type = "button"]:enabled:active, input[type = "submit"]:enabled:active { background: radial-gradient(#D7D3C2, #F3F0E0) !important; border-color: #5C0D12 !important } input[type = "text"], input[type = "date"], input[type = "password"], textarea { border: 2px solid #B5A4A4 } input:disabled, select:disabled, textarea:disabled { color: #C2A8A4; -webkit-text-fill-color: #C2A8A4 } input::placeholder, textarea::placeholder { color: #9F746F; -webkit-text-fill-color: #9F746F } input[type = "text"]:enabled:hover, input[type = "date"]:enabled:hover, input[type = "password"]:enabled:hover, textarea:enabled:hover, input[type = "text"]:enabled:focus, input[type = "date"]:enabled:focus, input[type = "password"]:enabled:focus, textarea:enabled:focus { background-color: #F3F0E0 } input[type = "file"] { border: 2px solid #B5A4A4 } .lc:hover input:enabled ~ span, .lr:hover input:enabled ~ span, .lc input:enabled:focus ~ span, .lr input:enabled:focus ~ span { background-color: #F3F0E0 !important; border-color: #977273 !important } .lc input:disabled ~ span, .lr input:disabled ~ span { border-color: #DBD4D3 !important } .lc > span { background-color: #EDEADA; border: 2px solid #B5A4A4 } .lc > span:after { border: solid #5C0D12 } .lr > span { background-color: #EDEADA; border: 2px solid #B5A4A4 } .lr > span:after { background: #5C0D12 } /* misc */ .br { color: #FF0000 } .stuffbox { background: #EDEBDF; border: 1px solid #5C0D12 } /* rating */ img.th { border: 1px solid #5C0D12 } div.ido { background: #EDEBDF; border: 1px solid #5C0D12 } /* index search/navigation */ .searchwarn { color: #D71F1F } .searchnav div > span { color: #CCCCCC } /* shared table stuff */ div.itg { border-top: 2px ridge #5C0D12; border-bottom: 2px ridge #5C0D12 } table.itg { border: 2px ridge #5C0D12 } table.itg > tbody > tr > th { background: #E0DED3 } table.itg > tbody > tr:nth-child(2n + 1), table.itg > tbody > tr:nth-child(2n + 1) .glthumb, table.itg > tbody > tr:nth-child(2n + 1) .glcut { background: #F2F0E4 } table.itg > tbody > tr:nth-child(2n + 2), table.itg > tbody > tr:nth-child(2n + 2) .glthumb, table.itg > tbody > tr:nth-child(2n + 2) .glcut { background: #EDEBDF } table.mt { border: 1px solid #5C0D12; background: #E0DED3 } table.mt > tbody > tr:nth-child(2n + 1) { background: #F2F0E4 } table.mt > tbody > tr:nth-child(2n + 2) { background: #EDEBDF } tr.gtr, table.mt > tbody > tr:first-child { background: #EBE8DD !important } td.itd { border-right: 1px solid #D9D7CC } /* login boxes */ div.d { border: 1px solid #5C0D12; background: #EDEBDF } div.ds { border: 1px solid #5C0D12; background: #EDEBDF } /* index */ div.idi { border: 2px ridge #5C0D12 } /* gallery list */ a:visited .glink, a:active .glink { color: #8F6063 } a:hover .glink { color: #8F4701 } .glname a :not(.glink), a .glname :not(.glink) { color: #5C0D11 } .glcat { border-right: 1px solid #D9D7CC } .glthumb { border: 2px solid #D9D7CC } .glthumb > div:nth-child(1) { border: 1px solid #5C0D12 } .gltc > tbody > tr > td, .glte > tbody > tr > td { border-right: 1px solid #D9D7CC } .gltm > tbody > tr > td { border-right: 1px solid #D9D7CC } .gl1c, .gl2c, .gl3c, .gl4c, .glfc { border-top: 1px solid #D9D7CC; border-bottom: 1px solid #D9D7CC } .gl1e, .gl2e, .glfe { border-top: 1px solid #D9D7CC; border-bottom: 1px solid #D9D7CC } .gl1e > div { border: 1px solid #5C0D12 } .gl4e { border-left: 1px solid #D9D7CC } .gld { border-left: 1px solid #D9D7CC } .gl1t { border-right: 1px solid #D9D7CC; border-bottom: 1px solid #D9D7CC } .gl3t { border: 1px solid #5C0D12 } .gl1t:nth-child(2n + 1) { background: #F2F0E4 } .gl1t:nth-child(2n + 2) { background: #EDEBDF } /* category buttons */ .ct1 { background: radial-gradient(#707070, #9e9e9e); border: 1px solid #707070 } /* misc */ .ct2 { background: radial-gradient(#fc4e4e, #f26f5f); border: 1px solid #fc4e4e } /* doujinshi */ .ct3 { background: radial-gradient(#e78c1a, #fcb417); border: 1px solid #e78c1a } /* manga */ .ct4 { background: radial-gradient(#c7bf07, #dde500); border: 1px solid #c7bf07 } /* artistcg */ .ct5 { background: radial-gradient(#1a9317, #05bf0b); border: 1px solid #1a9317 } /* gamecg */ .ct6 { background: radial-gradient(#2756aa, #5f5fff); border: 1px solid #2756aa } /* imageset*/ .ct7 { background: radial-gradient(#8800c3, #9755f5); border: 1px solid #8800c3 } /* cosplay */ .ct8 { background: radial-gradient(#b452a5, #fe93ff); border: 1px solid #b452a5 } /* asianporn */ .ct9 { background: radial-gradient(#0f9ebd, #08d7e2); border: 1px solid #0f9ebd } /* nonh */ .cta { background: radial-gradient(#5dc13b, #14e723); border: 1px solid #5dc13b } /* western */ /* page selector */ table.ptt { color: #5C0D12 } table.ptt td { background: #E3E0D1; border: 1px solid #5C0D12 } table.ptt td:hover { color: #9B4E03; background: #F2EFDF } table.ptb { color: #5C0D12 } table.ptb td { background: #E3E0D1; border: 1px solid #5C0D12 } table.ptb td:hover { color: #9B4E03; background: #F2EFDF } td.ptds { color: #9B4E03 !important; background: #F2EFDF !important } td.ptdd:hover { color: #C2A8A4 !important; background: #E3E0D1 !important } /* gallery */ a.tup { color: green } a.tdn { color: red } span.tup { color: green } span.tdn { color: red } div.gm { background: #EDEBDF; border: 1px solid #5C0D12 } div#gmid { background: #EDEBDF } div#gd1 div { border: 1px solid #5C0D12 } div#gd2 { background: #EDEBDF } h1#gj { color: #9F8687; border-bottom: 1px solid #5C0D12 } div#gd4 { border-left: 1px solid #5C0D12; border-right: 1px solid #5C0D12 } div#gdt { background: #EDEBDF; border: 1px solid #5C0D12 } div#gdt img { border: 1px solid #5C0D12 } .g3 a { color: #FF0000 } div.gt { border: 1px solid #806769; background: #F2EFDF } div.gtl { border: 1px dashed #9a7c7e; background: #F2EFDF } div.gtw { border: 1px dotted #9a7c7e; background: #F2EFDF } #gds { background: #F2EFDF } #grl { color: #FF0000 } div.c2 { background: #E3E0D1; border: 1px solid #F2EFDF } div.ths { border: 1px solid #806769; background: #F2EFDF } div.tha { border: 1px solid #C2A8A4 } div.tha:hover { background: #F2EFDF; color: #9B4E03 } div.thd { border: 1px solid #C2A8A4; color: #C2A8A4 } /* image pages */ div.sni { background: #EDEBDF; border: 1px solid #5C0D12 }` // These are extracted from the style tags in document.head, including the first checkbox fix. scientificLightStyles += ` /* keep the ticks in checkboxes */ .lc > span:after { border-width: 0 3px 3px 0 !important; } /* page-specific */` if (/exhentai\.org\/g\/\d+\/[0-9a-z]+\/\?act=expunge/.test(windowUrl)) { scientificLightStyles += ` #gdt.exp_outer { border-color: #5C0D12; } .exp_entry { border-color: #B5A4A4; } .exp_table { border-color: #5C0D12; }` } else if (/exhentai\.org\/mpv\//.test(windowUrl)) { scientificLightStyles += ` div.mi0 { background: #F2EFDF; border: 1px solid #E3E0D1; }` } else if (windowUrl.includes('favorites.php')) { scientificLightStyles += ` div.fp:hover { background:#F3F0E0; } div.fps { background:#F3F0E0; } @supports (display:grid) { @media screen and (max-width:1080px) { .gl1t:nth-child(8n + 1), .gl1t:nth-child(8n + 3), .gl1t:nth-child(8n + 6), .gl1t:nth-child(8n + 8) { background: #F2F0E4; } .gl1t:nth-child(8n + 2), .gl1t:nth-child(8n + 4), .gl1t:nth-child(8n + 5), .gl1t:nth-child(8n + 7) { background: #EDEBDF; } } }` } else if (windowUrl.includes('mytags')) { scientificLightStyles += ` #usertags_mass > div { border-top: 1px solid #5C0D12; } .jscolor { border: 1px solid #B5A4A4; } .tagcomplete-items { border: 1px solid #B5A4A4; } .tagcomplete-items div { background-color: #EDEBDF; } .tagcomplete-items div:not(:last-child) { border-bottom: 1px solid #D4D4D4; } .tagcomplete-items div:last-child { border-bottom: 1px solid #B5A4A4; } .tagcomplete-items div:hover { background-color: #F3F0E0; } .tagcomplete-active { background-color: #5C0D12 !important; color:#EDEBDF; }` } else if (windowUrl.includes('managegallery')) { scientificLightStyles += ` td.l { border-bottom: 1px solid #5c0d12; border-right: 1px dashed #5c0d12; } td.r { border-bottom: 1px solid #5c0d12; } td#d { border-right: 1px dashed #5c0d12; } div[id ^= "cell_"] { background: #f3f0e0; border: 1px solid #e3e0d1; }` // The upload list also has styles in document.head, but they are the same on both sides. There are only two // effective colour properties and they already fit the light theme, so nothing needs to be done. } else if (windowUrl.includes('gallerytorrents.php')) { scientificLightStyles += ` table#ett { background: #F2EFDF; border: 1px solid #5C0D12; } div#etd { background: #F2EFDF; border: 1px solid #5C0D12; }` } else if (windowUrl.includes('archiver.php')) { scientificLightStyles += ` div#db { border: 1px solid #5C0D12; background: #EDEBDF; }` } // These styles cover the styles not included in the default light styles in style sheet and style tags, so they // cannot be scientifically produced. Some of them also override the scientific styles. let customLightStyles = '' if (/exhentai\.org\/g\/\d+\/[0-9a-z]+/.test(windowUrl)) { // The first two rules already exist in the light style sheet, but they were removed during the style extraction // process. The last rule below targets the content warning div when it is there; otherwise it targets .gm, but it // will not have an effect beacuse this element already uses this background colour. customLightStyles += ` div#tagpopup { background: #EDEBDF; border: 1px solid #5C0D12 } div#tagpopup h2:hover { color: #9B4E03 } #nb + div { background: #EDEBDF !important }` } else if (/exhentai\.org\/mpv\//.test(windowUrl)) { customLightStyles += ` div.mi2, div.mi3, div#bar3 img { filter: invert(0.8); }` } else if (windowUrl.includes('karma.php')) { // Surprisingly enough, this page also exists on EX, but it is identical to the EH version so nothing needs to be // done. } const scientificLightStylesElement = appendStyleText(document.documentElement, 'scientificLightStyles', scientificLightStyles) appendStyleText(document.documentElement, 'customLightStyles', customLightStyles) /** * Appends the styles whose applicability can only be determined at the "interactive" ready state. */ const addStylesAtInteractive = function () { // The existing "displayMode" variable is not used due to its asynchronous assignment. const displayMode = document.querySelector('.searchnav option[selected = "selected"]') if (displayMode !== null && displayMode.textContent.toLowerCase() === 'thumbnail') { // These are extracted from the style tag in document.head of the search index in the thumbnail display mode. scientificLightStylesElement.textContent += ` @supports(display:grid) { @media screen and (max-width:1080px) { .gl1t:nth-child(8n + 1), .gl1t:nth-child(8n + 3), .gl1t:nth-child(8n + 6), .gl1t:nth-child(8n + 8) { background: #F2F0E4; } .gl1t:nth-child(8n + 2), .gl1t:nth-child(8n + 4), .gl1t:nth-child(8n + 5), .gl1t:nth-child(8n + 7) { background: #EDEBDF; } } @media screen and (max-width:860px) { .gl1t:nth-child(2n + 1) { background: #F2F0E4; } .gl1t:nth-child(2n + 2) { background: #EDEBDF; } } }` } } scheduleForInteractive(addStylesAtInteractive) } /** * Relocates the thumbnail pane and its scroll bar to the right side in the MPV, which should be more natural to use. * * This function is simple enough to be added at the "loading" ready state to avoid visible transitions. */ const relocateMpvThumbnails = function () { if (!/e(?:-|x)hentai\.org\/mpv\/\d+/.test(windowUrl)) { return } const mpvRelocationStyles = ` div#pane_thumbs { left: auto; right: 0px; z-index: 1; } div#pane_images { left: 0; } div#bar2 { float: left; } div#bar3 > img[title="Show Thumbnail Pane"] { transform: scaleX(-1); }` appendStyleText(document.documentElement, 'mpvRelocationStyles', mpvRelocationStyles) } /** * Hides the vertical toolbar in the MPV, which can rest on top of images, and only reveals it on hover. */ const hideMpvToolbar = function () { if (!/e(?:-|x)hentai\.org\/mpv\/\d+/.test(windowUrl)) { return } // div.mi2 and div.mi3 have "z-index: 2", so "z-index: 3" is needed below to ensure the toolbar will show on hover. const mpvToolbarStyles = ` div#bar1 { position: absolute; z-index: 3; opacity: 0; transition-duration: 0.3s; } div#bar1:hover { opacity: 1; }` appendStyleText(document.head, 'mpvToolbarStyles', mpvToolbarStyles) } /** * Runs feature functions at the "interactive" ready state, which belong to feature function group 2 to 6. */ const runFeaturesAtInteractive = function () { // If the Firefox compatibility mode is enabled, then the features run at the "loading" ready state by default will // be loaded here instead to ensure they will always be able to run on Firefox, at the cost of causing noticeable // visual changes when they are loaded. if (settings.script.firefoxCompatibilityEnabled) { // Group 1: functions that can run before DOM elements are loaded. settings.applyDarkTheme.featureEnabled && applyDarkTheme() settings.applyLightTheme.featureEnabled && applyLightTheme() settings.relocateMpvThumbnails.featureEnabled && relocateMpvThumbnails() settings.hideMpvToolbar.featureEnabled && hideMpvToolbar() } initialiseAtInteractive() // Group 2: functions that need to happen very quickly. // Currently empty. // Group 3: functions that need to run before others to change what content is displayed. settings.applyAdditionalFilters.featureEnabled && applyAdditionalFilters() settings.applyTextFilters.featureEnabled && applyTextFilters() settings.applyDesignFixes.featureEnabled && applyDesignFixes() settings.improveNavigationBar.featureEnabled && improveNavigationBar() settings.addVigilanteLinks.featureEnabled && addVigilanteLinks() settings.showAlternativeRating.featureEnabled && showAlternativeRating() settings.addGuideLinks.featureEnabled && addGuideLinks() // Group 4: functions that change how content is displayed. settings.applySubjectiveFixes.featureEnabled && applySubjectiveFixes() settings.emptyCategoryFilter.featureEnabled && emptyCategoryFilter() settings.fitThumbnailTitles.featureEnabled && fitThumbnailTitles() settings.colourCodeGalleries.featureEnabled && colourCodeGalleries() settings.fitViewerToScreen.featureEnabled && fitViewerToScreen() settings.fitMpvToScreen.featureEnabled && fitMpvToScreen() // Group 5: functions that require user input to activate and can hence load late. addControlPanel() settings.useAutomatedDownloads.featureEnabled && useAutomatedDownloads() settings.openGalleriesSeparately.featureEnabled && openGalleriesSeparately() settings.addJumpButtons.featureEnabled && addJumpButtons() settings.parseExternalLinks.featureEnabled && parseExternalLinks() settings.removeMpvTooltips.featureEnabled && removeMpvTooltips() // Group 6: functions that are not immediately required. settings.collectDawnReward.featureEnabled && collectDawnReward() } /** * Prepares shared variables and CSS styles to support functions running at the "interactive" ready state. */ const initialiseAtInteractive = function () { displayMode = document.querySelector('.searchnav > div:last-child option[selected = "selected"]') if (displayMode !== null) { // This includes the popular list, which lacks the top and bottom page numbers and the search result message. pageType = 'gallery list' displayMode = displayMode.textContent.toLowerCase() } else if (/e-hentai\.org\/toplist\.php\?tl=(?:11|12|13|15)/.test(windowUrl)) { // These are gallery toplists and they use a different format from the front page with a lot of elements and // functions missing. Control panel, page download and additional filters are not supported on these lists. pageType = 'gallery list' // Gallery toplists always use the compact display mode. displayMode = 'compact' } else if (/e(?:-|x)hentai\.org\/g\/\d+\/[0-9a-z]+/.test(windowUrl)) { if (xpathSelector(document, './/a[text() = "Get Me Outta Here"]') !== null) { pageType = 'content warning' } else { pageType = 'gallery view' } } else if (/e(?:-|x)hentai\.org\/mpv\/\d+\/[0-9a-z]+/.test(windowUrl)) { pageType = 'MPV view' } else if (/e(?:-|x)hentai\.org\/s\/[0-9a-z]+/.test(windowUrl)) { pageType = 'image view' } else if (/upld\.e-hentai\.org|exhentai\.org\/upld/.test(windowUrl)) { pageType = 'upload management' } else if (windowUrl.includes('forums.e-hentai.org')) { pageType = 'EH forums' } else if (windowUrl.includes('hentaiverse.org')) { pageType = 'HentaiVerse' } else { pageType = 'other' } // Add the compulsory styles that are either always needed or required by multiple features. In the styles below: // 1. "z-index" on config buttons is needed for pony mode compatibility: #cancelConfigButton needs "z-index: 4" // because otherwise it would be totally blocked by ponies, which have "z-index: 3". #saveConfigButton hence also // uses this for consistency. #openConfigButton does not use this since it is not fully blocked, and this allows // the ponies to be fully visible when the control panel is not open. // 2. "min-height" on #controlPanel tr prevents empty rows from being collapsed and allows text wrap in each row // when needed. // 3. The three buttons added by this script and the display mode selector have been made vertically symmetrical, // but some browsers may randomly create subpixel inaccuracy that vertically misalign the buttons by like 0.1 px. const requiredCommonStyles = ` /* control panel */ #controlPanel tr { display: block; min-height: 35px; padding: 0 10px; line-height: 35px; } #controlPanel tr.indent1 { padding-left: 28px; } #controlPanel tr.indent2 { padding-left: 46px; } #controlPanel .boldText { font-weight: bold; } input[type = "checkbox"] { margin: 0 5px 0 0; } #controlPanel input[type = "text"] { padding: 3px 5px; margin: 0 5px; } #controlPanel select { padding: 3px 1px; margin: 0 5px; } /* Display mode selector style buttons */ input[type = "button"].dmsStyleButtons { min-height: 27px; font-weight: bold; padding: 4px; line-height: normal; margin: 0 1px 1px; } #openConfigButton { width: 140px; border-radius: 3px; } #saveConfigButton { width: 70px; border-radius: 3px 0 0 3px; border-right: 0; margin-right: 0; } #cancelConfigButton { width: 70px; border-radius: 0 3px 3px 0; border-left: 0; margin-left: 0; } #additionalFiltersButton { width: 170px; }` appendStyleText(document.head, 'requiredCommonStyles', requiredCommonStyles) } /** * Applies additional third-stage gallery list filters to all types of gallery lists, except for gallery toplists. * * This feature includes three filters, which remove galleries from gallery lists based on ratings and favorite * categories given by the user, and displayed gallery titles. The first two filters work on gallery chains and * securely hide individual galleries and their future updates. A problem that cannot be fixed is that the rated * filter cannot detect yellow stars, which is not enabled by default but can be manually configured in the EH gallery * settings. */ const applyAdditionalFilters = function () { // This feature runs on all gallery lists except for gallery toplists, because they do not show rated and favorited // statuses like other gallery lists. Although the gallery title filter can still work on toplists, this filter is // disabled there to keep the application of filters consistent. It is also disabled when the list has already been // emptied by site filters. if (pageType !== 'gallery list' || windowUrl.includes('toplist.php') || !document.querySelector('.glink')) { return } const shortcuts = settings.applyAdditionalFilters const gallerySelector = displayMode === 'thumbnail' ? '.itg.gld > .gl1t' : 'tbody > tr' let filteredCount = 0 /** * Removes rated galleries to which certain numbers of stars have been given by the user. * * Note that by default the stars on rated galleries will be red, green or blue, but in the user's EH gallery * settings, it is also possible to use yellow stars that are identical to stars on unrated galleries. It is * technically impossible to distinguish rated galleries with yellow stars from unrated ones, so yellow stars cannot * be supported. */ const filterRatedGalleries = function () { // This filter can be disabled by the user on the favorite and/or popular list via control panel. if (windowUrl.includes('favorites.php') && shortcuts.ratedFilterExceptionEnabled && shortcuts.ratedFilterExceptions.includes('the favorite list')) { return } else if (windowUrl.includes('popular') && shortcuts.ratedFilterExceptionEnabled && shortcuts.ratedFilterExceptions.includes('the popular list')) { return } let ratedMarks // The class names of red, green and blue stars are .ir.irr, .ir.irg and .ir.irb respectively, but that of yellow // stars is just .ir, which is identical to ordinary yellow stars on unrated galleries. Each gallery also has two // rating elements due to the thumbnail overlays in the first three display modes below. switch (displayMode) { case 'minimal': case 'minimal+': ratedMarks = document.querySelectorAll('.gl4m > .irr, .gl4m > .irg, .gl4m > .irb') break case 'compact': ratedMarks = document.querySelectorAll('div[id ^= "posted_"] + .irr, div[id ^= "posted_"] + .irg, ' + 'div[id ^= "posted_"] + .irb') break case 'extended': case 'thumbnail': ratedMarks = document.querySelectorAll('.irr, .irg, .irb') } if (shortcuts.ratedFilterStars === 'all') { removeGalleries(ratedMarks) } else { const targetChildNodes = [] for (const ratedMark of ratedMarks) { const starSheetCoordinates = ratedMark.style.backgroundPosition.match(/(0|-\d+)px (-1|-21)px/) const starsGiven = 5 - (+starSheetCoordinates[1] / -16) - (+starSheetCoordinates[2] === -1 ? 0 : 0.5) if (shortcuts.ratedFilterStars.includes(starsGiven)) { targetChildNodes.push(ratedMark) } } removeGalleries(targetChildNodes) } } /** * Removes favorited galleries which have been added to certain favorite categories by the user. */ const filterFavoritedGalleries = function () { // This filter is always disabled on the favorite list, and can be disabled by the user on the popular list via // control panel. if (windowUrl.includes('favorites.php')) { return } else if (windowUrl.includes('popular') && shortcuts.favoritedFilterExceptionEnabled) { return } // This is not affected by the display mode, because the duplicates of these elements below thumbnail overlays // use a different id format. const favoritedMarks = document.querySelectorAll('div[id ^= "posted_"][title]') if (shortcuts.favoritedFilterCategories === 'all') { removeGalleries(favoritedMarks) } else { const targetChildNodes = [] for (const favoritedMark of favoritedMarks) { if (shortcuts.favoritedFilterCategories.includes(favoritedMark.title.toLowerCase())) { targetChildNodes.push(favoritedMark) } } removeGalleries(targetChildNodes) } } /** * Removes galleries whose displayed titles in gallery lists have at least one match with the keywords or regular * expression set by the user. * * Note that the displayed titles in gallery lists can be the english/romanised titles, or the original Japanese * titles depending on the "gallery name display" option in the user's EH gallery settings, so the user should be * aware of this when entering the keywords or regular expression. */ const filterGallleryTitles = function () { // This filter can be disabled by the user on the favorite and/or popular list via control panel. if (windowUrl.includes('favorites.php') && shortcuts.titleFilterExceptionEnabled && shortcuts.titleFilterExceptions.includes('the favorite list')) { return } else if (windowUrl.includes('popular') && shortcuts.titleFilterExceptionEnabled && shortcuts.titleFilterExceptions.includes('the popular list')) { return } const galleryTitles = document.querySelectorAll('.glink') const targetChildNodes = [] if (shortcuts.titleFilterType === 'one of the keywords') { for (const galleryTitle of galleryTitles) { const title = galleryTitle.textContent.toLowerCase() for (const keyword of shortcuts.titleFilterKeywords) { if (title.includes(keyword)) { targetChildNodes.push(galleryTitle) break } } } } else { const filterRegex = new RegExp(shortcuts.titleFilterKeywords, 'i') for (const galleryTitle of galleryTitles) { if (filterRegex.test(galleryTitle.textContent)) { targetChildNodes.push(galleryTitle) } } } removeGalleries(targetChildNodes) } /** * Helps other functions to locate gallery DOM elements from their child nodes and remove these galleries. * * @param {HTMLElement[]} targetChildNodes - A potentially empty NodeList containing the child nodes of the * galleries to be removed. */ const removeGalleries = function (targetChildNodes) { if (targetChildNodes.length > 0) { for (const targetChildNode of targetChildNodes) { const gallery = targetChildNode.closest(gallerySelector) gallery.parentNode.removeChild(gallery) } filteredCount += targetChildNodes.length } } /** * Adds a new message to mention how many galleries have been excluded by additional filters. */ const updateResultMessage = function () { // The additional result message is not added when the default search result message is not present, like on the // favourite list and toplists. if (filteredCount === 0 || !document.querySelector('div.searchtext')) { return } const resultMessage = document.querySelector('div.searchtext') // This additional sentence about galleries removed by default filters can be disabled in gallery settings, but // it only slightly changes the message shown by this function and does not matter. const defaultFiltersActive = resultMessage.textContent.indexOf('removed') !== -1 // Create and simply add the new message below the default one to avoid modifying the latter with the button. const additionalMessage = document.createElement('p') additionalMessage.id = 'additionalFiltersMessage' // This padding is set to achieve a mostly consistent line spacing between messages on the watched page and also // between messages and search navigation buttons in general. additionalMessage.style.paddingTop = '6px' additionalMessage.textContent += `MEMS filters ${defaultFiltersActive ? 'further ' : ''}excluded ` + `${filteredCount} ${filteredCount > 1 ? 'galleries' : 'gallery'}, and ` // Check how many galleries are still being shown after all filtering. const visibleCount = document.getElementsByClassName('glink').length if (visibleCount > 0) { additionalMessage.textContent += `${visibleCount} ${visibleCount > 1 ? 'galleries are' : 'gallery is'} shown ` + 'on this page.' } else { // Clear one set of search navigation and the list element when all results have been removed by this feature. // This look replicates what the design fixes feature does when site filters emptied the results. The first set // of navigation buttons is kept since there can be results on other pages. const listParent = document.querySelector('#toppane + div') listParent.removeChild(listParent.querySelector('.itg')) listParent.removeChild(listParent.querySelector('.searchnav:last-of-type')) additionalMessage.textContent += 'all galleries have been hidden on this page.' } resultMessage.appendChild(additionalMessage) } shortcuts.ratedFilterEnabled && filterRatedGalleries() shortcuts.favoritedFilterEnabled && filterFavoritedGalleries() shortcuts.titleFilterEnabled && filterGallleryTitles() updateResultMessage() } /** * Applies user and word filters to selectively remove gallery comments, forum posts and forum threads. * * It also includes a spam filter, but it is not needed at the moment. */ const applyTextFilters = function () { if (pageType !== 'gallery view' && pageType !== 'EH forums') { return } // Checking for the script elements below is more secure and readable than checking the URL, which may vary even for // the same page type. let forumPageType if (pageType === 'EH forums') { if (document.querySelector('script[src = "jscripts/ipb_topic.js"]') !== null) { forumPageType = 'thread view' } else if (document.querySelector('script[src = "jscripts/ipb_forum.js"]') !== null) { forumPageType = 'forum view' } else if (document.querySelector('script[src = "jscripts/ipb_board.js"]') !== null) { forumPageType = 'board view' } else if (document.getElementById('navstrip').textContent.includes('Search Engine') && windowUrl.includes('result_type=posts')) { // This is the view when post or thread search results are displayed as posts. forumPageType = 'thread-post view' } } // Spam definition is stored here for now. const spamDefinition = ['zeo.kr', 'ssumro.xyz', 'sex4doll.com'] const shortcuts = settings.applyTextFilters /** * Removes comments in gallery view by checking the commentator names for blocked users. */ const filterCommentsByUsername = function () { const commentList = document.getElementById('cdiv') const commentators = commentList.querySelectorAll('.c3 > a:first-of-type') for (const commentator of commentators) { if (shortcuts.commentatorFilterUsernames.includes(commentator.textContent)) { commentList.removeChild(commentator.closest('.c1')) } } } /** * Removes comments in gallery view by checking the comment contents for blocked keywords. */ const filterCommentsByKeyword = function () { const commentList = document.getElementById('cdiv') const comments = document.querySelectorAll('.c6') for (const comment of comments) { const keywords = decideKeywords(shortcuts.commentFilterKeywords) for (const keyword of keywords) { if (comment.textContent.toLowerCase().includes(keyword)) { commentList.removeChild(comment.closest('.c1')) break } } } } /** * Removes posts in thread view and thread-post view by checking the poster names for blocked users. */ const filterPostsByUsername = function () { let posters if (forumPageType === 'thread view') { posters = document.querySelectorAll('.bigusername > a') } else { posters = document.querySelectorAll('.normalname a') } for (const poster of posters) { if (shortcuts.posterFilterUsernames.includes(poster.textContent)) { removePost(poster) } } } /** * Removes posts in thread view and thread-post view by checking the post contents for blocked keywords. */ const filterPostsByKeyword = function () { const posts = document.querySelectorAll('.postcolor') const keywords = decideKeywords(shortcuts.postFilterKeywords) for (const post of posts) { for (const keyword of keywords) { if (post.textContent.toLowerCase().includes(keyword)) { removePost(post) break } } } } /** * Removes threads in forum view by checking the thread starter names for blocked users. */ const filterThreadsByUsername = function () { const threadList = document.querySelector('.borderwrap > .ipbtable > tbody') // The class name of the td below is different between the two forum themes and can be either .row1 or .row2. const posters = threadList.querySelectorAll('td > a[href ^= "https://forums.e-hentai.org/index.php?showuser="]') for (const poster of posters) { // Usernames are not converted to lowercase. if (shortcuts.posterFilterUsernames.includes(poster.textContent)) { threadList.removeChild(poster.closest('tr')) } } } /** * Removes threads in forum view by checking the thread titles for blocked keywords. */ const filterThreadsByKeyword = function () { const threadList = document.querySelector('.borderwrap > .ipbtable > tbody') const threads = threadList.querySelectorAll('a[id ^= "tid-link-"][title]') const keywords = decideKeywords(shortcuts.postFilterKeywords) for (const thread of threads) { for (const keyword of keywords) { if (thread.textContent.toLowerCase().includes(keyword)) { threadList.removeChild(thread.closest('tr')) break } } } } /** * Censors blocked usernames in the "last action" column in forum view. */ const censorLastActionByUsername = function () { const lastActionUsers = document.querySelectorAll('.lastaction > b > ' + 'a[href ^= "https://forums.e-hentai.org/index.php?showuser="]') for (const lastActionUser of lastActionUsers) { if (shortcuts.posterFilterUsernames.includes(lastActionUser.textContent)) { lastActionUser.textContent = '<blocked user>' } } } /** * Censors blocked usernames in the "last post info" column in board view. */ const censorLastPostByUsername = function () { const lastPostUsers = document.querySelectorAll('a[title = "Go to the last post"] + span > ' + 'a[href ^= "https://forums.e-hentai.org/index.php?showuser="]') for (const lastPostUser of lastPostUsers) { if (shortcuts.posterFilterUsernames.includes(lastPostUser.textContent)) { lastPostUser.textContent = '<blocked user>' } } } /** * Censors thread titles with blocked keywords in the "last post info" column in board view. */ const censorLastPostByKeyword = function () { const lastPostThreads = document.querySelectorAll('a[title *= "Go to the first unread post:"]') const keywords = decideKeywords(shortcuts.postFilterKeywords) for (const lastPostThread of lastPostThreads) { for (const keyword of keywords) { if (lastPostThread.textContent.toLowerCase().includes(keyword)) { lastPostThread.textContent = '<blocked thread>' break } } } } /** * Joins the spam definition with user-defined keywords to produce the array of keywords to be blocked. * * @param {(string[]|string)} defaultKeywords - An empty string or non-empty array of user-defined strings to block. * @returns {string[]} A non-empty array of strings containing the user-defined strings and/or the spam definition. */ const decideKeywords = function (defaultKeywords) { if (shortcuts.spamFilterEnabled) { if (Array.isArray(defaultKeywords)) { return defaultKeywords.concat(spamDefinition) } else { return spamDefinition } } else { // In this case, this function is called because a keyword-blocking sub-feature is enabled, so defaultKeywords // must be an array because the control panel requires a non-empty list when it is enabled. return defaultKeywords } } /** * Helps other functions to locate DOM elements for a post from its child node and remove this post. * * @param {HTMLElement[]} targetChildNode - A child node of the main body element of the post to be removed. */ const removePost = function (targetChildNode) { // The elements in the two forum themes are named differently. The postList in the fusion theme is tried first // since it is easier. let postList = document.getElementById('ipbwrapper') if (!postList) { postList = document.querySelector('div.page > div:not(.copyright)') } const postBody = targetChildNode.closest('.borderwrap') // This function handles both thread view and thread-post view; the difference is there is one extra element for // each post in thread view. if (forumPageType === 'thread view') { // The header above each post is not a child of the post element outside search results, so it needs to be // removed separately. postList.removeChild(postBody.previousElementSibling) } // Remove <br> to ensure consistent spacing. postList.removeChild(postBody.previousElementSibling) postList.removeChild(postBody) } // Check the specific page type and run applicable functions that have been enabled. if (pageType === 'gallery view') { if (shortcuts.commentatorFilterEnabled) { filterCommentsByUsername() } if (shortcuts.commentFilterEnabled || shortcuts.spamFilterEnabled) { filterCommentsByKeyword() } } else if (forumPageType === 'thread view' || forumPageType === 'thread-post view') { if (shortcuts.posterFilterEnabled && shortcuts.posterFilterType.includes('posts')) { filterPostsByUsername() } if ((shortcuts.postFilterEnabled && shortcuts.postFilterType.includes('posts')) || shortcuts.spamFilterEnabled) { filterPostsByKeyword() } } else if (forumPageType === 'forum view') { if (shortcuts.posterFilterEnabled && shortcuts.posterFilterType.includes('threads')) { filterThreadsByUsername() } if ((shortcuts.postFilterEnabled && shortcuts.postFilterType.includes('threads')) || shortcuts.spamFilterEnabled) { filterThreadsByKeyword() // For the forum view where a list of subforums is also displayed on top. censorLastPostByKeyword() } if (shortcuts.posterFilterEnabled && shortcuts.posterFilterType.includes('posts')) { censorLastActionByUsername() // For the forum view where a list of subforums is also displayed on top. censorLastPostByUsername() } } else if (forumPageType === 'board view') { if (shortcuts.posterFilterEnabled && shortcuts.posterFilterType.includes('posts')) { censorLastPostByUsername() } if ((shortcuts.postFilterEnabled && shortcuts.postFilterType.includes('threads')) || shortcuts.spamFilterEnabled) { censorLastPostByKeyword() } } } /** * Fixes website design problems throughout the gallery system. * * Currently this function changes what is displayed so it is in function group 3. The fixes in this feature should * be useful to Tenboro, although there are still a few more bugs that cannot be fixed here. */ const applyDesignFixes = function () { // This feature does not have fixes for HV. if (pageType === 'HentaiVerse') { return } // Redirect to a working search page when directly searching for an uploader whose username contains a forward slash // (/) by clicking the uploader username in gallery view, because the username is not encoded and the site will // interpret the slash as part of the URL and return 404 not found. if (/e(?:-|x)hentai\.org\/uploader\/.+?%2F/.test(windowUrl)) { const uploader = windowUrl.match(/e(?:-|x)hentai\.org\/uploader\/(.+)/)[1] document.location.href = `https://e-hentai.org/?f_cats=0&f_search=uploader%3A${uploader}` return } let designFixesStyles = '' if (pageType === 'gallery list') { // Add a sentence to say how many galleries are visible after all filters, because it is still possible for the // page to show less than a full page of 25/50/100 galleries after the search engine upgrade. Since this feature // function runs after the additional filters feature, the effects of additional filters will be included and the // correct count will be shown. const additionalFiltersMessage = document.querySelector('#additionalFiltersMessage') // If a message from the additional filters feature is already present, then that feature has already shown a // count and removed the table when needed. Consequently no work needs to be done in this case. The gallery // toplists are excluded since they do not have this message. Then, this fix will not run when the default search // result message is not present, like on the favourite list and toplists. Some of the code below is shared with // the additional filters. if (!additionalFiltersMessage && document.querySelector('div.searchtext')) { const visibleGalleries = document.querySelectorAll('.glink').length let currentCountMessage if (visibleGalleries > 0) { currentCountMessage = `Currently showing ${visibleGalleries} galleries on this page.` } else { // If the "no unfiltered results in this page range" table is shown, it means the default filters already // emptied the page and the additional filters did not activate. This table or box has a few problems: // 1. The colspan of this message's row in minimal(+) display modes is one column short. // 2. The table header row exists in all display modes other than extended, which is inconsistent, and it // should not exist in the thumbnail gallery list display mode, which does not use a details table. // This table will be removed and replaced by the consistent sentence added below. // The sentence will use the same message as the table. currentCountMessage = document.body.querySelector('.itg td[colspan]:not([class])').textContent const listParent = document.querySelector('#toppane + div') listParent.removeChild(listParent.querySelector('.itg')) listParent.removeChild(listParent.querySelector('.searchnav:last-of-type')) } const additionalMessage = document.createElement('p') additionalMessage.textContent = currentCountMessage // This padding is set to acheive a visually consistent line spacing between messages on the watched page. additionalMessage.style.paddingTop = '6px' document.querySelector('div.searchtext').appendChild(additionalMessage) } } else if (pageType === 'upload management') { // The styles in document.head of the upload list are still the same on both sides. There are only two effective // colour properties, but they only fit the light theme, so a fix is needed for the dark theme. if ((windowUrl.includes('e-hentai.org') && settings.applyDarkTheme.featureEnabled) || (windowUrl.includes('exhentai.org') && !settings.applyLightTheme.featureEnabled)) { // The colours for the two sorting arrows are supposed to show borders for the active arrow only. These dark // theme colours are the colour of the table header text and that of the hearder background, which are // consistent with the colour selection in the light theme. designFixesStyles += ` /* fix dark theme sorting arrow colours in upload management */ img.s { border-color: #f1f1f1 } img.u { border-color: #40454b }` } } else if (pageType === 'EH forums') { // Use a relative limit instead of an absolute one to ensure images in posts will fit inside the viewport. This // applies to both thread view and thread-post view. designFixesStyles += ` /* limit relative size of images in forum posts */ .postcolor img { max-width: 100% !important; }` } else if (windowUrl.includes('exhentai.org/tos.php')) { // Redirect the terms of service page in the EX upload interface, because this EX version does not exist. window.location.assign(windowUrl.replace('exhentai.org', 'e-hentai.org')) } else if (windowUrl.includes('tools.php?act=track_rename')) { // Allow titles to wrap to next line on rename tracker to ease reading and remove the need to scroll horizontally. designFixesStyles += ` /* wrap submitted rename titles on rename tracker */ body > div > div > div > div { white-space: unset !important; } ` } if (designFixesStyles.length > 0) { appendStyleText(document.documentElement, 'designFixesStyles', designFixesStyles) } } /** * Improves the top navigation bar in the gallery system by adding a few additional buttons. */ const improveNavigationBar = function () { // This feature obviously cannot run when the bar is not there, and the forums and HV are screened out first. if (pageType === 'EH forums' || pageType === 'HentaiVerse') { return } const navigationBar = document.getElementById('nb') if (!navigationBar) { return } // Use a consistent style on both sides. navigationBar.style.maxWidth = '1300px' navigationBar.style.justifyContent = 'center' // Allow the bar to take more than one line when needed. navigationBar.style.maxHeight = 'initial' if (windowUrl.includes('exhentai.org')) { addNavigationButton(navigationBar, 'Forums', 'https://forums.e-hentai.org/') addNavigationButton(navigationBar, 'Wiki', 'https://ehwiki.org/') // This does not relicate the "HentaiVerse" -> "HV" behaviour from the span elements when screen width is limited. addNavigationButton(navigationBar, 'HentaiVerse', 'https://hentaiverse.org/') if (windowUrl.includes('exhentai.org/upld/')) { addNavigationButton(navigationBar, 'To E-Hentai', windowUrl.replace('exhentai.org/upld/', 'upld.e-hentai.org/')) } else { addNavigationButton(navigationBar, 'To E-Hentai', windowUrl.replace('exhentai.org', 'e-hentai.org')) } } // Start to add the unread count buttons. The en dash in these buttons are as wide as a digit, so it is used to // prevent the width of the navigation bar from changing after these elements are updated by XHRs. if (!settings.improveNavigationBar.unreadCountsEnabled) { return } const domParser = new DOMParser() /** * Uses GET or POST to request a page via XHR, and runs the supplied function on load. * * @param {string} targetUrl - The URL to which this XHR will be sent. * @param {Function} onloadFunction - The function that will run on the successful response from this XHR. */ function fetchUnreadCount (targetUrl, onloadFunction) { // The XHR will not retry on network or HTML error, because the error may persist for a while and the user // cannot cancel this endless retry process. const xhrDetails = { method: 'GET', synchronous: false, timeout: 60000, url: targetUrl, onload: function (response) { if (response.status === 200) { const documentReceived = domParser.parseFromString(response.responseText, 'text/html') onloadFunction(documentReceived) } }, ontimeout: function (response) { fetchUnreadCount() } } const errorHandler = runtimeError => {} if (api.version === 'v4') { api.xmlHttpRequest(xhrDetails).catch(errorHandler) } else { xhrDetails.onerrror = errorHandler api.xmlHttpRequest(xhrDetails) } } /** * Fetches the EH forum front page to read the "* New Messages" part and update the unread PM button. * * @param {Document} documentReceived - The parsed document returned by the caller XHR. */ function updateUnreadPmCount (documentReceived) { const unreadCountButton = xpathSelector(document, './/a[text() = "PM: –"]') const newMessagesButton = documentReceived.querySelector('#userlinks a[href *= "act=Msg"]') // Check whether this inbox link exists and hence whether the user is logged in; then update the link if // the user is logged in. if (newMessagesButton !== null) { const unreadPmCount = newMessagesButton.textContent.match(/\d+/)[0] unreadCountButton.textContent = 'PM: ' + unreadPmCount if (unreadPmCount > 0) { unreadCountButton.style.color = 'red' } } } /** * Fetches the MM inbox page to check for unread mails and update the unread MM button. * * @param {Document} documentReceived - The parsed document returned by the caller XHR. */ function updateUnreadMmCount (documentReceived) { const unreadCountButton = xpathSelector(document, './/a[text() = "MM: –"]') if (!documentReceived.getElementById('mmail_outer')) { // Do nothing because the MM inbox page was not received, likely because the user is in a battle. } else if (documentReceived.getElementById('mmail_nnm') !== null) { // Check for the "no new mail" indicator. unreadCountButton.textContent = 'MM: 0' } else { // Only the rows that represent actual MMs will have an onclick property. unreadCountButton.textContent = 'MM: ' + documentReceived.querySelectorAll('#mmail_list tr[onclick]').length unreadCountButton.style.color = 'red' } } /** * Fetches the karma log to check timestamps for new karma messages and update the unread +K button. * * @param {Document} documentReceived - The parsed document returned by the caller XHR. */ function updateUnreadKarmaCount (documentReceived) { const unreadCountButton = xpathSelector(document, './/a[text() = "+K: –"]') const karmaTable = documentReceived.getElementsByTagName('table')[0] // How this page will look when the user does not have any karma message is not known, so the empty karma // page scenario here is tested in two ways. The value for last karma read is not recorded so the next two // branches below will work as intended. if (typeof karmaTable === 'undefined' || documentReceived.querySelector('#lb + div').textContent.match(/Total Karma: (\d+)/)[1] === '0') { unreadCountButton.textContent = '+K: 0' return } // When this unread +K check is done for the first time, assume the user has read the latest karma message // and record this timestamp as read. if (values.improveNavigationBar.lastKarmaRead === '') { values.improveNavigationBar.lastKarmaRead = karmaTable.rows[1].firstElementChild.textContent api.setValue('values', JSON.stringify(values)) unreadCountButton.textContent = '+K: 0' } else { let unreadKarmaCount = 0 for (const row of karmaTable.rows) { if (row.firstElementChild.textContent !== 'Date') { // Compare the timestamp with that of the last recorded +K message that has been read. A date // comparison is not necessary since this list is always sorted in descending order. if (row.firstElementChild.textContent !== values.improveNavigationBar.lastKarmaRead) { unreadKarmaCount += 1 } else { break } } } unreadCountButton.textContent = `+K: ${unreadKarmaCount}` if (unreadKarmaCount > 0) { unreadCountButton.style.color = 'red' } } } // Add a button that shows the number of unread forum PMs and links to the inbox. If the button is clicked when the // user is not logged in, the error page displayed will allow the user to log in. addNavigationButton(navigationBar, 'PM: –', 'https://forums.e-hentai.org/index.php?act=Msg&CODE=01') fetchUnreadCount('https://forums.e-hentai.org/', updateUnreadPmCount) // Add a button that shows the number of unread mooglemails and links to the MM inbox. addNavigationButton(navigationBar, 'MM: –', 'https://hentaiverse.org/?s=Bazaar&ss=mm') fetchUnreadCount('https://hentaiverse.org/?s=Bazaar&ss=mm', updateUnreadMmCount) // Add a button that shows the number of unread +K messages and links to the karma log. Since the karma log does not // offer an unread count itself, the script needs to keep track of the last message read. if (windowUrl === 'https://e-hentai.org/logs.php?t=karma') { const karmaTable = document.getElementsByTagName('table')[0] // How this page will look when the user does not have any karma message is not known, so the empty karma // page scenario here is tested in two ways like in updateUnreadKarmaCount(). if (typeof karmaTable === 'undefined' || document.querySelector('#lb + div').textContent.match(/Total Karma: (\d+)/)[1] === '0') { addNavigationButton(navigationBar, '+K: –', 'https://e-hentai.org/logs.php?t=karma') } else { addNavigationButton(navigationBar, '+K: 0', 'https://e-hentai.org/logs.php?t=karma') // Record the timestamp of the latest karma message and update the userscript storage if needed. const latestKarmaTimestamp = karmaTable.rows[1].firstElementChild.textContent if (values.improveNavigationBar.lastKarmaRead !== latestKarmaTimestamp) { values.improveNavigationBar.lastKarmaRead = latestKarmaTimestamp api.setValue('values', JSON.stringify(values)) } } } else { addNavigationButton(navigationBar, '+K: –', 'https://e-hentai.org/logs.php?t=karma') fetchUnreadCount('https://e-hentai.org/logs.php?t=karma', updateUnreadKarmaCount) } } /** * Adds links to the main gallery maintenance threads in the vigilante subforum to the gallery information pane. */ const addVigilanteLinks = function () { if (pageType !== 'gallery view') { return } /** * Adds a link button to the list of links on the right side of the gallery information pane. * * @param {string} text - The visible text content of the link. * @param {string} url - The destination URL of the link. * @param {string} className - The class name to be applied to this anchor element. */ const addLinkItem = function (text, url, className) { const paragraph = document.createElement('p') paragraph.className = className const image = document.createElement('img') image.src = 'https://ehgt.org/g/mr.gif' const anchor = document.createElement('a') anchor.textContent = text anchor.href = url anchor.onclick = function (anchorEvent) { anchorEvent.preventDefault() window.open(url) } paragraph.appendChild(image) paragraph.appendChild(anchor) document.getElementById('gd5').appendChild(paragraph) } // max-height: 100% is needed on div#gd4 below to limit the height of the tagging area to prevent its side borders // from overflowing when a tag is selected. div#gwrd is the tag loading GIF and its position needs to be fixed // when this feature is active. let vigilanteLinksStyles = ` div#gd4 { width: 570px; max-height: 100%; } #tagmenu_new { width: auto !important; } div#gd5 { width: 160px; margin-top: -5px; } .gsp { padding-top: 12px; } div#gwrd { top: -30px; }` // When there is an advertisement, there will be a #spa element between #gd3 and #gd4 that can take at least 600 x // 60px space. The "report gallery" link is also too close to the advertisement, and the fix below is a design fix. if (document.getElementById('spa') !== null) { vigilanteLinksStyles += ` .g2, .g3 { padding-bottom: 6px; } .g3 { padding-top: 6px }` } else { vigilanteLinksStyles += ` .g2, .g3 { padding-bottom: 8px; }` } // The colour of the forum links is calculated by blending the background colour of div#gright and the colour of // sibling anchors in the ratio of 1:1. if ((windowUrl.includes('e-hentai.org') && settings.applyDarkTheme.featureEnabled) || (windowUrl.includes('exhentai.org') && !settings.applyLightTheme.featureEnabled)) { vigilanteLinksStyles += ` .g2.forum > a { color: #96989C; }` } else { vigilanteLinksStyles += ` .g2.forum > a { color: #A57C78; }` } appendStyleText(document.head, 'vigilanteLinksStyles', vigilanteLinksStyles) // These links are ordered by thread size. addLinkItem(' Tagging Assistance', 'https://forums.e-hentai.org/index.php?showtopic=184081', 'g2 forum gsp') addLinkItem(' Tag Namespacing', 'https://forums.e-hentai.org/index.php?showtopic=246656', 'g2 forum') addLinkItem(' Renaming & Reclassing', 'https://forums.e-hentai.org/index.php?showtopic=227712', 'g2 forum') addLinkItem(' Expunge Assistance', 'https://forums.e-hentai.org/index.php?showtopic=242797', 'g2 forum') addLinkItem(' Comment Cleanup', 'https://forums.e-hentai.org/index.php?showtopic=247567', 'g2 forum') } /** * Shows a more objective alternative rating system inside galleries, which is based on the ratio of favorited/rated. * * The ratio rating equals the ratio of times favorited to times rated, and a descriptive rating based on this ratio * is also provided. The ratio rating should be a better quality measure than the star rating and the difference * between them can be very significant, because the former is much less polarised, more distinct, and somewhat immune * to excessive downvoting. */ const showAlternativeRating = function () { if (pageType !== 'gallery view') { return } // This constant specifies the distribution of descriptive ratings derived from ratios. In each ratio-description // pair below, the description will be used for the descriptive rating when the gallery's calculated ratio is equal // to or greater than the ratio. const ratioLevels = [ { ratio: 10, description: 'masterwork' }, { ratio: 7, description: 'excellent' }, { ratio: 4, description: 'good' }, { ratio: 1, description: 'average' }, { ratio: 0, description: 'unpopular' } ] /** * Appends a two-cell row to the information table on the left side of the gallery information pane. * * @param {string} leftCellText - The text to be shown in the left cell of this new row. * @param {string} rightCellText - The text to be shown in the right cell of this new row. */ const addTableRow = function (leftCellText, rightCellText) { const informationTable = document.getElementById('gdd').firstElementChild.firstElementChild const newRow = informationTable.insertRow(-1) const leftCell = newRow.insertCell() leftCell.className = 'gdt1' leftCell.textContent = leftCellText const rightCell = newRow.insertCell() rightCell.className = 'gdt2' rightCell.textContent = rightCellText } const timesFavorited = +document.getElementById('favcount').textContent.replace(/\D/g, '') const timesRated = +document.getElementById('rating_count').textContent if (settings.showAlternativeRating.hideStarsEnabled) { document.getElementById('gdr').style.display = 'none' document.getElementById('gdf').style.paddingTop = '0' // When the star rating section is hidden, a row will be added to the table to show this rating for comparison. if (timesRated !== 0) { const starRating = +document.getElementById('rating_label').textContent.replace('Average: ', '') if (isNaN(starRating)) { // It is possible for starRating to be NaN, but it cannot be reproduced and how it happened is still unknown. addTableRow('Rating:', 'N/A') } else { addTableRow('Rating:', starRating + (starRating <= 1 ? ' star' : ' stars')) } } else { addTableRow('Rating:', 'Not yet rated') } } else { document.getElementById('gdr').style.marginTop = '15px' document.getElementById('gdf').style.paddingTop = '15px' } // Add a row to the table to show the ratio of times favorited to times rated, and the derived descriptive rating. if (timesRated >= 20) { // The ratio will be rounded down to one decimal place. const ratio = Math.floor((timesFavorited / timesRated) * 10) / 10 // Go through the thresholds in descending order and use the floor for this gallery's descriptive rating. ratioLevels.push({ ratio, description: 'this gallery' }) ratioLevels.sort((a, b) => a.ratio - b.ratio) let i = ratioLevels.length // The gallery's ratio must be greater than zero, so the loop does not need to check whether it is the last, // smallest value in the array. while (--i) { if (ratioLevels[i].description === 'this gallery') { let description // Check for the case where the ratio is equal to a threshold and placed just before this threshold in the // array. The symmetrical case where it is placed just after this threshold is included in the else branch. if (typeof ratioLevels[i + 1] !== 'undefined' && ratioLevels[i].ratio === ratioLevels[i + 1].ratio) { description = ratioLevels[i + 1].description } else { description = ratioLevels[i - 1].description } addTableRow('Fav/Rate:', `${ratio} (${description})`) } } } else { // Do not show the ratio when there are not enough star ratings and hence gallery visits to calculate reliable // results. Using 20 as the threshold here provides a good balance between availability and reliability. addTableRow('Fav/Rate:', 'Not enough data') } } /** * Adds links to gallery upload guides to the upload management bar. */ const addGuideLinks = function () { if (pageType !== 'upload management') { return } const managementBar = document.getElementById('lb') let guideLinkColour // The colour of the guide links is calculated by blending the background colour of body and the colour of // sibling anchors in the ratio of 1:1. if ((windowUrl.includes('e-hentai.org') && settings.applyDarkTheme.featureEnabled) || (windowUrl.includes('exhentai.org') && !settings.applyLightTheme.featureEnabled)) { guideLinkColour = '#89898C' } else { guideLinkColour = '#A07771' } /** * Adds a anchor element to the upload management bar as a child node in the same style as its siblings. * * @param {string} text - The visible text content of the anchor element, excluding the enclosing square brackets. * @param {string} url - The destination URL of the anchor element. */ const addGuideAnchor = function (text, url) { managementBar.appendChild(document.createTextNode('\u00A0\u00A0\u00A0')) const anchor = document.createElement('a') // Unlike its sibling anchors, the square brackets are included in this anchor to change their colour as well. anchor.textContent = `[${text}]` anchor.href = url anchor.style.color = guideLinkColour anchor.onclick = function (anchorEvent) { anchorEvent.preventDefault() window.open(url) } managementBar.appendChild(anchor) } managementBar.lastChild.nodeValue = ']' addGuideAnchor('Upload Guide', 'https://ehwiki.org/wiki/Making_Galleries') addGuideAnchor('Category Guide', 'https://ehwiki.org/wiki/Gallery_Categories#Flow_Chart') addGuideAnchor('Naming Guide', 'https://ehwiki.org/wiki/Renaming#Naming_Style') addGuideAnchor('Tagging Guide', 'https://ehwiki.org/wiki/Gallery_Tagging') addGuideAnchor('Upload FAQ', 'https://ehwiki.org/wiki/E-Hentai_Galleries_FAQ#Uploading') addGuideAnchor('Upload Troubleshooting', 'https://ehwiki.org/wiki/Technical_Issues#Your_Galleries') } /** * Applies subjective style fixes to make some elements look better and more consistent in general. */ const applySubjectiveFixes = function () { let subjectiveFixesStyles = '' if (pageType !== 'EH forums' && pageType !== 'HentaiVerse') { // The vertical spacing above the links at the very bottom, such as "terms of service", is inconsistent across // page types and somtimes too small. A consistent spacing of 5px is used above these links. Most page have // inconsistent but sufficient spacing so they are not changed. subjectiveFixesStyles += ` /* image view */ div#i1 + script + script + div.dp { margin-top: ${settings.fitViewerToScreen.featureEnabled ? '5px' : '-1px'} !important; } /* news */ div#newsouter + div.dp { margin-top: 5px !important; }` // The margins around .stuffbox are inconsistent across pages under "my home". if (windowUrl.includes('uconfig.php') || windowUrl.includes('mytags')) { subjectiveFixesStyles += ` #outer.stuffbox { margin: 10px auto; }` } // Slightly adjust the placement of elements in the thumbnail gallery list display mode for better symmetry. if (displayMode === 'thumbnail') { subjectiveFixesStyles += ` .gl3t, .gl4t { margin-bottom: 3px; } .gl6t { padding-top: 3px; padding-bottom: 1px; }` } } if (pageType === 'gallery view') { // Manually fit the tag button to panel width. const buttonWidth = (settings.addVigilanteLinks.featureEnabled ? 570 : 580) - 10 - 4 - 480 - 2 - 2 - 2 subjectiveFixesStyles += ` #newtagbutton { width: ${buttonWidth}px; }` if (!settings.addVigilanteLinks.featureEnabled) { subjectiveFixesStyles += ` div#tagmenu_new > form { width: max-content; }` } } else if (pageType === 'EH forums') { // Tick the two checkboxes for new forum PMs to add sent PMs to sent items and track these messages by default. if (/forums\.e-hentai\.org\/index\.php\?(?:act=Msg(?:&CODE=0?4)?|CODE=0?4&act=Msg)/.test(windowUrl)) { // URL is https://forums.e-hentai.org/index.php?act=msg when there is an error sending PM. document.getElementsByName('add_sent')[0].checked = true document.getElementsByName('add_tracking')[0].checked = true } const newMessagesButton = document.querySelector('#userlinks a[href *= "act=Msg"]') if (typeof newMessagesButton !== 'undefined') { const unreadPmCount = newMessagesButton.textContent.match(/\d+/)[0] if (unreadPmCount > 0) { newMessagesButton.style.color = 'red' } } } if (subjectiveFixesStyles.length > 0) { appendStyleText(document.documentElement, 'subjectiveFixesStyles', subjectiveFixesStyles) } } /** * Unselects all category filter buttons in the search box by default unless they have been set after a search. * * This way it is easier to select one or two categories. It may not be a good idea to use this if the * user is using the front page setting in EH gallery settings, which allows the user to unselect some but not all * categories and also changes the galleries to be displayed by default. * * Note that this function only unselects the buttons and does not affect the galleries displayed unless the user * clicks the "apply filter" button to conduct a search. This is different from the front page setting. */ const emptyCategoryFilter = function () { // This function only runs on gallery lists that has a search box with unset category buttons. Whether or not the // buttons have been set after a search can be determined from the URL: // 1. When a search involving the category filter has been conducted, the URL would contain "f_cats" with values // other than 0 and 1023. // 2. when the user directly enters a particular category after clicking a category button for a gallery in gallery // list or gallery view, the URL would have the name of the category. if (pageType !== 'gallery list') { return } else if (!document.getElementById('searchbox')) { return } else if (/f_cats|doujinshi|manga|artistcg|gamecg|western|non-h|imageset|cosplay|asianporn|misc/.test(windowUrl)) { // Check for the "f_cats=0" and "f_cats=1023" cases where all category buttons were manually selected or // unselected by the user in a search. In these cases the category buttons are unset so this function can run. // Otherwise, if "f_cats" has another number, the buttons are already set and this function should not run, // because the category selection should not be changed. if (!/f_cats=(0|1023)/.test(windowUrl)) { return } } // Unselect all categories which have not been unselected yet. let i = 10 while (i--) { const categoryButton = document.getElementById(`cat_${1 << i}`) if (!categoryButton.hasAttribute('data-disabled')) { categoryButton.click() } } } /** * Adjusts the height of each row in the thumbnail gallery list display mode to show full gallery titles. */ const fitThumbnailTitles = function () { if (displayMode !== 'thumbnail') { return } // Fit the titles at first by increasing title heights to display up to 10 lines, which should allow all titles to // be displayed in full. The line height is 16px in the title elements. const thumbnailTitleStyles = ` .gl4t { max-height: 160px; }` appendStyleText(document.head, 'thumbnailTitleStyles', thumbnailTitleStyles) // Set the height of each title to the height of the tallest title in its row to create margin between shorter // titles and their thumbnails, so that all thumbnails will stay aligned in each row. const titles = document.getElementsByClassName('glname') const titleOffsetHeights = Array.from(titles, title => title.offsetHeight) let i = 0 while (i < titleOffsetHeights.length) { const rowMaxHeight = Math.max(...titleOffsetHeights.slice(i, i + 5)) const rowEndIndex = Math.min(i + 5, titleOffsetHeights.length) - 1 do { if (titleOffsetHeights[i] < rowMaxHeight) { titles[i].style.height = `${rowMaxHeight}px` } } while (++i <= rowEndIndex) } } /** * Applies colour coding to new and expunged galleries on all types of gallery lists, except for gallery toplists. * * This feature will colour the titles and timestamps of new galleries blue and those of expunged galleries red to * make them easier to spot. The titles of new galleries will also be made bold for further enhancement and * consistency with the timestamps. */ const colourCodeGalleries = function () { // This feature runs on all gallery lists except for gallery toplists, because the elements there differ from other // types of gallery lists and gallery statuses are not shown besides the expunged timestamp. if (pageType !== 'gallery list' || windowUrl.includes('toplist.php')) { return } const colourCodingStyles = ` .glnew, tr[data-new] .glink, div[data-new] .glink { color: #22A7F0; font-weight: bold; } div[id ^= "posted_"] > s, tr[data-expunged] .glink, div[data-expunged] .glink { color: #D91E18; }` appendStyleText(document.head, 'colourCodingStyles', colourCodingStyles) } /** * Fits images to screen instead of width in the basic image viewer and adds a button to temporarily toggle the fit. */ const fitViewerToScreen = function () { if (pageType !== 'image view') { return } let fitImageStyles = ` /* stretch to fill screen */ #i1 { height: calc(100vh - 10px); width: 95vw !important; max-width: 95vw !important; padding: 0; } #i3, #i3 > a > img { height: 100% !important; width: 100% !important; margin: 0; } /* maintain aspect ratio and fit to screen */ #i3 > a > img { object-fit: contain; max-width: initial !important; max-height: initial !important; } /* reposition elements */ #topControlGroup, #bottomControlGroup { display: flex; justify-content: center; } #topControlGroup > h1, #i2, #i4, #i5, #i6, #i7 { position: absolute; } #topControlGroup > h1, div.if { margin: 0; } #topControlGroup > h1 { width: 90vw; top: 0; padding-top: 10px; white-space: nowrap; overflow: hidden; } #i2 { top: 0; padding-top: 35px; } #i7 { bottom: 0; padding-bottom: 20px; } #i6 { bottom: 0; padding-bottom: 40px; } #i5 { bottom: 0; padding-bottom: 55px; } #i4 { bottom: 0; padding-bottom: 80px; } div.sni { position: initial; } p.ip { display: none; } /* add hover animation and shadow */ #topControlGroup, #bottomControlGroup { opacity: 0; transition-duration: 0.3s; } #topControlGroup:hover, #bottomControlGroup:hover { opacity: 1; } #topControlGroup > h1, #i2, #i4, #i5, #i6, #i7 { text-shadow: 0 1px 3px #000000, 1px 0 3px #000000, 0 -1px 3px #000000, -1px 0 3px #000000; } #topControlGroup, #bottomControlGroup, #i6 > a, #i7 > a { color: #f1f1f1 } div.sn img, div.sb img { filter: drop-shadow(0px 0px 3px #FFFFFF); } /* add additional shading on top and bottom */ div.sni { display: flex; position: relative; justify-content: center; } #topControlGroup, #bottomControlGroup { width: calc(95vw + 2px) ; position: absolute; background: rgba(0, 0, 0, 0.6); } #topControlGroup { height: 91px; top: 0; } #bottomControlGroup { height: 136px; bottom: -1px; } #toggleButtonHost { position: absolute; }` const persistentStyles = ` div.sni { margin: 2px auto; } /* button styles */ #toggleButtonHost { display: flex; justify-content: center; } #toggleFitButton { width: 155px; min-height: 25px; height: 25px; position: fixed; bottom: 2vh; opacity: 0; box-shadow: 0 0 7px 2px rgba(0, 0, 0, 0.6); transition-duration: 0.3s; } #toggleFitButton:hover { opacity: 1; }` fitImageStyles += persistentStyles const fitImageStylesElement = appendStyleText(document.head, 'fitImageStyles', fitImageStyles) // The control elements originally above and below images are grouped under two divisions to re-style them and // enable the visibility change on hover. const topControlGroup = document.createElement('div') topControlGroup.id = 'topControlGroup' topControlGroup.appendChild(document.querySelector('#i1 > h1')) topControlGroup.appendChild(document.getElementById('i2')) document.getElementById('i1').insertBefore(topControlGroup, document.getElementById('i3')) const bottomControlGroup = document.createElement('div') bottomControlGroup.id = 'bottomControlGroup' bottomControlGroup.appendChild(document.getElementById('i4')) bottomControlGroup.appendChild(document.getElementById('i5')) bottomControlGroup.appendChild(document.getElementById('i6')) bottomControlGroup.appendChild(document.getElementById('i7')) document.getElementById('i1').appendChild(bottomControlGroup) /** * Temporarily toggles fit to screen or width without affect the setting in the userscript storage. * * The effect of this toggle persists between pages due to the way images are loaded in the basic viewer. * * @type {clickEventHandler} * @param {MouseEvent} clickEvent - The event object passed to this event handler on click. */ const toggleImageFit = function (clickEvent) { const toggleButton = clickEvent.target if (toggleButton.value === 'Fit Images to Width') { // The original format is completely restored, since the hover control buttons are not very convenient. fitImageStylesElement.textContent = persistentStyles toggleButton.value = 'Fit Images to Screen' } else { fitImageStylesElement.textContent = fitImageStyles toggleButton.value = 'Fit Images to Width' } } const toggleButton = createDmsButton('toggleFitButton', 'Fit Images to Width', toggleImageFit) const toggleButtonHost = document.createElement('div') toggleButtonHost.id = 'toggleButtonHost' toggleButtonHost.appendChild(toggleButton) document.getElementById('i1').appendChild(toggleButtonHost) } /** * Fits images to screen instead of width in the MPV and adds a button to temporarily toggle the fit. */ const fitMpvToScreen = function () { if (pageType !== 'MPV view') { return } const shortcuts = settings.fitMpvToScreen let fitMpvStyles // Sometimes the image information below each image can be longer than the image in the MPS mode, and there is not a // way to always crop and fit it accurately between the buttons using CSS. Therefore, to show the text, the five // buttons below each image will be hidden by default and revealed when the pointer is hovering over the whole bar. // This way the buttons are always available and the full information should be displayed most of the time; // otherwise, when the text is too long, it will be truncated and suffixed with ellipsis at the end. if (shortcuts.mpsModeEnabled) { fitMpvStyles = ` /* stretch to fill screen */ div.mi0, img[id ^= "imgsrc_"] { height: calc(100vh - 2px) !important; width: auto !important; } /* maintain aspect ratio and fit to screen */ img[id ^= "imgsrc_"] { object-fit: contain; max-width: 100%; } /* remove default width limit and reposition the text and buttons below the image */ div.mi0 { display: inline-table; min-width: 0; max-width: 100% !important; } div.mi1 { display: flex; justify-content: center; height: 20px; padding: 5px 0 3px 0; } div.mi2, div.mi3 { position: absolute; float: unset; opacity: 0; transition-duration: 0.3s; } div.mi1:hover > div.mi2, div.mi1:hover > div.mi3 { opacity: 1; } div.mi2 { left: 0; } div.mi3 { right: 0; } div.mi4 { max-width: calc(100% - 10px); top: unset; left: unset; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }` } else { fitMpvStyles = ` /* stretch to fill screen */ div.mi0, img[id ^= "imgsrc_"] { height: calc(100vh - 2px) !important; width: 100% !important; } /* maintain aspect ratio and fit to screen */ img[id ^= "imgsrc_"] { object-fit: contain; } /* remove default width limit and reposition the text and buttons below the image */ div.mi0 { max-width: 100% !important; ${shortcuts.seamlessModeEnabled ? '' : 'padding-bottom: 30px; '}} div.mi1 { padding: 5px 0 3px 0; } div.mi4 { width: 60vw; position: initial; top: 5px; margin: 0 auto; white-space: nowrap; overflow: hidden; }` } let persistentStyles = ` /* remove top and bottom borders inside the image pane */ body { padding: 0px 2px; } #pane_images { height: 100vh !important; } /* button styles */ #toggleButtonHost { display: flex; justify-content: center; } #toggleFitButton { width: 155px; min-height: 25px; height: 25px; position: fixed; bottom: 2vh; opacity: 0; box-shadow: 0 0 7px 2px rgba(0, 0, 0, 0.6); transition-duration: 0.3s; } #toggleFitButton:hover { opacity: 1; }` if (shortcuts.seamlessModeEnabled) { persistentStyles += ` /* hide the information and buttons below each image */ div.mi1 { display: none; }` } fitMpvStyles += persistentStyles if (shortcuts.seamlessModeEnabled) { // This property is now included in "persistentStyles" but not in "fitMpvStyles" so as to avoid repetition. persistentStyles += ` div.mi0 { height: auto !important; }` } /** * Temporarily toggles fit to screen or width without affect the setting in the userscript storage. * * @type {clickEventHandler} * @param {MouseEvent} clickEvent - The event object passed to this event handler on click. */ const toggleMpvFit = function (clickEvent) { const toggleButton = clickEvent.target if (toggleButton.value === 'Fit Images to Width') { // The border rules are kept to keep the view consistent. document.getElementById('fitMpvStyles').textContent = persistentStyles toggleButton.value = 'Fit Images to Screen' } else { document.getElementById('fitMpvStyles').textContent = fitMpvStyles toggleButton.value = 'Fit Images to Width' } } let toggleButton if (shortcuts.makeDefaultEnabled) { appendStyleText(document.head, 'fitMpvStyles', fitMpvStyles) toggleButton = createDmsButton('toggleFitButton', 'Fit Images to Width', toggleMpvFit) } else { appendStyleText(document.head, 'fitMpvStyles', persistentStyles) toggleButton = createDmsButton('toggleFitButton', 'Fit Images to Screen', toggleMpvFit) } const toggleButtonHost = document.createElement('div') toggleButtonHost.id = 'toggleButtonHost' toggleButtonHost.appendChild(toggleButton) document.getElementById('pane_images').appendChild(toggleButtonHost) } /** * Adds a user-friendly control panel for this script to front page search results, and saves checked settings. * * The optional button for quickly toggling the additional filters is also added here if enabled. */ const addControlPanel = function () { // This feature does not run on toplists because of their legacy format, which requires a separate set of old code // to support. The control panel buttons also cannot be added on the favourite list due to a lack of space, but the // filter button can still be added there. if (pageType !== 'gallery list' || windowUrl.includes('toplist.php')) { return } const galleryList = document.getElementsByClassName('itg')[0] /** * Adds the "open", "save" and "cancel" buttons for using the control panel. */ const addConfigButtons = function () { // The control panel buttons are not added on the favourite list because their location is now taken by the new // order selector. Then, it is possible for the gallery list page to not have a list due to "no hits" and other // features of this script; in such cases, these buttons (and the page download button from automated downloads) // will not be added, but this feature function still needs to run to add the button for toggling additional // filters if needed. if (windowUrl.includes('favorites.php') || !document.querySelector('.itg')) { return } const openConfigButton = createDmsButton('openConfigButton', 'Configure MEMS', openControlPanel, 'Temporarily hide the gallery list and open the control panel') const saveConfigButton = createDmsButton('saveConfigButton', 'Save', function () { if (saveSettings()) { closeControlPanel() alert('Settings saved. Please reload the page to let your browser reload the script. Other open EH pages ' + 'will need to be reloaded as well to apply any changes you have made.') } }, 'Save the settings and close the control panel') const cancelConfigButton = createDmsButton('cancelConfigButton', 'Cancel', closeControlPanel, 'Close the ' + 'control panel without saving any changes') openConfigButton.style.display = 'unset' saveConfigButton.style.display = 'none' cancelConfigButton.style.display = 'none' // An ID is not added to this host becasue this function does not run on the favourite list, and the page download // button will find this host on its own. const configButtonHost = document.querySelector('.searchnav > div:first-child') configButtonHost.appendChild(openConfigButton) configButtonHost.appendChild(saveConfigButton) configButtonHost.appendChild(cancelConfigButton) } /** * Creates the control panel on demand and alters the gallery list to display it. */ const openControlPanel = function () { const controlPanel = createControlPanel() galleryList.parentNode.insertBefore(controlPanel, galleryList) galleryList.style.display = 'none' controlPanel.style.display = 'table' document.getElementById('openConfigButton').style.display = 'none' document.getElementById('saveConfigButton').style.display = 'unset' document.getElementById('cancelConfigButton').style.display = 'unset' } /** * Helps openControlPanel() to actually create the control panel using a table and fill in the rows. * * This is the only place in this script where features are grouped by applicable page type instead of load order. */ const createControlPanel = function () { const controlPanel = document.createElement('table') controlPanel.id = 'controlPanel' controlPanel.className = 'itg' // The spaces around "•" below are em quad characters. const scriptInfoRow = extendWithAnchor(appendRow(controlPanel, 0), undefined, `${api.info.script.name} ` + `v${api.info.script.version}`, 'https://openuserjs.org/scripts/Mayriad/Mayriads_EH_Master_Script', true) scriptInfoRow.appendChild(document.createTextNode(' • ')) extendWithAnchor(scriptInfoRow, undefined, 'GitHub Repository', 'https://github.com/Mayriad/Mayriads-EH-Master-Script', true) scriptInfoRow.appendChild(document.createTextNode(' • ')) extendWithAnchor(scriptInfoRow, undefined, 'User Manual', 'https://github.com/Mayriad/Mayriads-EH-Master-Script/blob/master/README.md', true) scriptInfoRow.appendChild(document.createTextNode(' • ')) extendWithAnchor(scriptInfoRow, undefined, 'Support Thread', 'https://forums.e-hentai.org/index.php?showtopic=233955', true) // Sitewide features --------------------------------------------------------------------------------------------- controlPanel.insertRow(-1) extendWithStrongText(appendRow(controlPanel, 0), undefined, 'Site-wide features') // applyDarkTheme extendWithCheckBox(appendRow(controlPanel, 0), 'applyDarkTheme-featureEnabled', settings.applyDarkTheme.featureEnabled, 'Apply a full, scientific dark theme to the gallery system where ' + 'applicable') // applyLightTheme extendWithCheckBox(appendRow(controlPanel, 0), 'applyLightTheme-featureEnabled', settings.applyLightTheme.featureEnabled, 'Apply a full, scientific light theme to the gallery system where ' + 'applicable') // applyDesignFixes extendWithCheckBox(appendRow(controlPanel, 0), 'applyDesignFixes-featureEnabled', settings.applyDesignFixes.featureEnabled, 'Fix website design problems throughout the gallery system') // applySubjectiveFixes extendWithCheckBox(appendRow(controlPanel, 0), 'applySubjectiveFixes-featureEnabled', settings.applySubjectiveFixes.featureEnabled, 'Apply subjective fixes to make a few elements more convenient ' + 'and look better in the gallery system and the forum board') // improveNavigationBar extendWithCheckBox(appendRow(controlPanel, 0), 'improveNavigationBar-featureEnabled', settings.improveNavigationBar.featureEnabled, 'Improve the top navigation bar in the gallery system by ' + 'adding a few additional buttons (one option below)') extendWithCheckBox(appendRow(controlPanel, 1), 'improveNavigationBar-unreadCountsEnabled', settings.improveNavigationBar.unreadCountsEnabled, 'Show the numbers of unread forum PMs, HV MMs and +K ' + 'messages when you are logged in') // applyTextFilters // A limit is not really needed, but the total length of every text input under this feature is limited to 255 // characters, which would at least prevent performance degradation if it actually matters. extendWithCheckBox(appendRow(controlPanel, 0), 'applyTextFilters-featureEnabled', settings.applyTextFilters.featureEnabled, 'Apply user and word filters to selectively remove gallery ' + 'comments, forum posts and forum threads (a few options below)') const commentatorFilterEnabledRow = extendWithCheckBox(appendRow(controlPanel, 1), 'applyTextFilters-commentatorFilterEnabled', settings.applyTextFilters.commentatorFilterEnabled, 'Hide gallery comments made by the following users:') extendWithTextInput(commentatorFilterEnabledRow, 'applyTextFilters-commentatorFilterUsernames', joinPotentialArray(settings.applyTextFilters.commentatorFilterUsernames), 255, '(separate usernames by ' + 'comma)', 'Enter up to 255 characters, case sensitive') const commentFilterEnabledRow = extendWithCheckBox(appendRow(controlPanel, 1), 'applyTextFilters-commentFilterEnabled', settings.applyTextFilters.commentFilterEnabled, 'Hide gallery comments that contain any of the following keywords:') extendWithTextInput(commentFilterEnabledRow, 'applyTextFilters-commentFilterKeywords', joinPotentialArray(settings.applyTextFilters.commentFilterKeywords), 255, '(separate keywords and/or ' + 'phrases by comma)', 'Enter up to 255 characters, case insensitive') const posterFilterEnabledRow = extendWithCheckBox(appendRow(controlPanel, 1), 'applyTextFilters-posterFilterEnabled', settings.applyTextFilters.posterFilterEnabled, 'Hide') extendWithOptionSelector(posterFilterEnabledRow, 'applyTextFilters-posterFilterType', settings.applyTextFilters.posterFilterType, options.applyTextFilters.posterFilterType, 'made by the ' + 'following users:') extendWithTextInput(posterFilterEnabledRow, 'applyTextFilters-posterFilterUsernames', joinPotentialArray(settings.applyTextFilters.posterFilterUsernames), 255, '(separate usernames by comma)', 'Enter up to 255 characters, case sensitive') const postFilterEnabledRow = extendWithCheckBox(appendRow(controlPanel, 1), 'applyTextFilters-postFilterEnabled', settings.applyTextFilters.postFilterEnabled, 'Hide') extendWithOptionSelector(postFilterEnabledRow, 'applyTextFilters-postFilterType', settings.applyTextFilters.postFilterType, options.applyTextFilters.postFilterType, 'that contain any ' + 'of the following keywords:') extendWithTextInput(postFilterEnabledRow, 'applyTextFilters-postFilterKeywords', joinPotentialArray(settings.applyTextFilters.postFilterKeywords), 255, '(separate keywords and/or phrases ' + 'by comma)', 'Enter up to 255 characters, case insensitive') extendWithCheckBox(appendRow(controlPanel, 1), 'applyTextFilters-spamFilterEnabled', settings.applyTextFilters.spamFilterEnabled, 'Hide spam comments, posts and threads in galleries and ' + 'forums using the built-in spam definition (usually not needed)') // addJumpButtons const addJumpButtonsEnabledRow = extendWithCheckBox(appendRow(controlPanel, 0), 'addJumpButtons-featureEnabled', settings.addJumpButtons.featureEnabled, 'Add') extendWithOptionSelector(addJumpButtonsEnabledRow, 'addJumpButtons-jumpButtonStyle', settings.addJumpButtons.jumpButtonStyle, options.addJumpButtons.jumpButtonStyle, 'to the bottom right corner ' + 'which will') extendWithOptionSelector(addJumpButtonsEnabledRow, 'addJumpButtons-jumpBehaviourStyle', settings.addJumpButtons.jumpBehaviourStyle, options.addJumpButtons.jumpBehaviourStyle, 'scroll the page to ' + 'the very top or bottom') // collectDawnReward extendWithCheckBox(appendRow(controlPanel, 0), 'collectDawnReward-featureEnabled', settings.collectDawnReward.featureEnabled, 'Extends the availability of the daily dawn reward event so that ' + 'it can be collected from any EH-related page') // Gallery list features ----------------------------------------------------------------------------------------- controlPanel.insertRow(-1) extendWithStrongText(appendRow(controlPanel, 0), undefined, 'Gallery list features') // applyAdditionalFilters extendWithCheckBox(appendRow(controlPanel, 0), 'applyAdditionalFilters-featureEnabled', settings.applyAdditionalFilters.featureEnabled, 'Apply additional third-stage gallery list filters to all ' + 'types of gallery lists, except for gallery toplists (a few options below)') // It is expected that, at most, each value will take 3 characters and they will be separated by 2 characters, // which means a length limit of 48 characters should be imposed. const ratedFilterEnabledRow = extendWithCheckBox(appendRow(controlPanel, 1), 'applyAdditionalFilters-ratedFilterEnabled', settings.applyAdditionalFilters.ratedFilterEnabled, 'Hide the galleries that you have rated at') extendWithTextInput(ratedFilterEnabledRow, 'applyAdditionalFilters-ratedFilterStars', joinPotentialArray(settings.applyAdditionalFilters.ratedFilterStars), 48, 'stars in the past, including ' + 'their updates (separate numbers by comma, or enter "all" to hide all rated galleries)', 'Enter up to 48 ' + 'characters, case insensitive') const ratedFilterExceptionEnabledRow = extendWithCheckBox(appendRow(controlPanel, 2), 'applyAdditionalFilters-ratedFilterExceptionEnabled', settings.applyAdditionalFilters.ratedFilterExceptionEnabled, 'Always temporarily disable this rated gallery ' + 'filter on') extendWithOptionSelector(ratedFilterExceptionEnabledRow, 'applyAdditionalFilters-ratedFilterExceptions', settings.applyAdditionalFilters.ratedFilterExceptions, options.applyAdditionalFilters.ratedFilterExceptions) // The site limits the name of each favorite category to 20 characters and they are expected to be separated by 2 // characters at most, which means a length limit of 218 characters should be imposed. const favoritedFilterEnabledRow = extendWithCheckBox(appendRow(controlPanel, 1), 'applyAdditionalFilters-favoritedFilterEnabled', settings.applyAdditionalFilters.favoritedFilterEnabled, 'Hide the galleries that you have added to the favorite categories of') extendWithTextInput(favoritedFilterEnabledRow, 'applyAdditionalFilters-favoritedFilterCategories', joinPotentialArray(settings.applyAdditionalFilters.favoritedFilterCategories), 218, ', including their ' + 'updates (separate categories by comma, or enter "all" to hide all categories)', 'Enter up to 218 ' + 'characters, case insensitive') extendWithCheckBox(appendRow(controlPanel, 2), 'applyAdditionalFilters-favoritedFilterExceptionEnabled', settings.applyAdditionalFilters.favoritedFilterExceptionEnabled, 'Always temporarily disable this favorited ' + 'gallery filter on the popular list') // A limit is not really needed, but the total length of the input is limited to 255 characters for consistency. const titleFilterEnabledRow = extendWithCheckBox(appendRow(controlPanel, 1), 'applyAdditionalFilters-titleFilterEnabled', settings.applyAdditionalFilters.titleFilterEnabled, 'Hide all galleries whose title has at least') extendWithOptionSelector(titleFilterEnabledRow, 'applyAdditionalFilters-titleFilterType', settings.applyAdditionalFilters.titleFilterType, options.applyAdditionalFilters.titleFilterType, 'of') extendWithTextInput(titleFilterEnabledRow, 'applyAdditionalFilters-titleFilterKeywords', joinPotentialArray(settings.applyAdditionalFilters.titleFilterKeywords), 255, '(separate keywords by comma ' + 'if not using a regular expression)', 'Enter up to 255 characters, case insensitive if not using a regular ' + 'expression') const titleFilterExceptionEnabledRow = extendWithCheckBox(appendRow(controlPanel, 2), 'applyAdditionalFilters-titleFilterExceptionEnabled', settings.applyAdditionalFilters.titleFilterExceptionEnabled, 'Always temporarily disable this gallery title ' + 'filter on') extendWithOptionSelector(titleFilterExceptionEnabledRow, 'applyAdditionalFilters-titleFilterExceptions', settings.applyAdditionalFilters.titleFilterExceptions, options.applyAdditionalFilters.titleFilterExceptions) // emptyCategoryFilter extendWithCheckBox(appendRow(controlPanel, 0), 'emptyCategoryFilter-featureEnabled', settings.emptyCategoryFilter.featureEnabled, 'Unselect all category filter buttons in the search box by ' + 'default unless they have been set after a search, so that it is easier to select one or two categories') // fitThumbnailTitles extendWithCheckBox(appendRow(controlPanel, 0), 'fitThumbnailTitles-featureEnabled', settings.fitThumbnailTitles.featureEnabled, 'Adjust the height of each row in the thumbnail gallery list ' + 'display mode to show full gallery titles') // colourCodeGalleries extendWithCheckBox(appendRow(controlPanel, 0), 'colourCodeGalleries-featureEnabled', settings.colourCodeGalleries.featureEnabled, 'Apply colour coding to new and expunged galleries on all ' + 'types of gallery lists, except for gallery toplists, by colouring the titles and timestamps of new ' + 'galleries blue and those of expunged galleries red') // useAutomatedDownloads extendWithCheckBox(appendRow(controlPanel, 0), 'useAutomatedDownloads-featureEnabled', settings.useAutomatedDownloads.featureEnabled, 'Add download shortcut buttons to automatically download ' + 'galleries directly from all types of gallery lists (many options below)') extendWithCheckBox(appendRow(controlPanel, 1), 'useAutomatedDownloads-torrentDownloadEnabled', settings.useAutomatedDownloads.torrentDownloadEnabled, 'Enable torrent download and prioritise it over ' + 'archive download whenever a gallery has torrents') const torrentRequirementsEnabledRow = extendWithCheckBox(appendRow(controlPanel, 2), 'useAutomatedDownloads-torrentRequirementsEnabled', settings.useAutomatedDownloads.torrentRequirementsEnabled, 'Only prioritise it when the gallery has an up-to-date torrent with at least') extendWithTextInput(torrentRequirementsEnabledRow, 'useAutomatedDownloads-minimumSeedNumber', settings.useAutomatedDownloads.minimumSeedNumber, 1, 'seeds, but ignore this and download any torrent when ' + 'the gallery is larger than', 'Enter an integer between 1 and 9, inclusive') extendWithTextInput(torrentRequirementsEnabledRow, 'useAutomatedDownloads-ignoreRequirementsSize', settings.useAutomatedDownloads.ignoreRequirementsSize, 4, 'MB, or if archive download is disabled', 'Enter ' + 'an integer between 0 and 9999, inclusive') extendWithCheckBox(appendRow(controlPanel, 2), 'useAutomatedDownloads-personalisedTorrentEnabled', settings.useAutomatedDownloads.personalisedTorrentEnabled, 'Download personalised torrents instead of ' + 'redistributable torrents when you are logged in to avoid occasional torrent download errors') extendWithCheckBox(appendRow(controlPanel, 2), 'useAutomatedDownloads-apiTorrentDownloadEnabled', settings.useAutomatedDownloads.apiTorrentDownloadEnabled, 'Use the more reliable GM.download() method to ' + 'download torrents') const archiveDownloadEnabledRow = extendWithCheckBox(appendRow(controlPanel, 1), 'useAutomatedDownloads-archiveDownloadEnabled', settings.useAutomatedDownloads.archiveDownloadEnabled, 'Download this type of archive when torrent download is disabled above or unavailable:') extendWithOptionSelector(archiveDownloadEnabledRow, 'useAutomatedDownloads-archiveDownloadType', settings.useAutomatedDownloads.archiveDownloadType, options.useAutomatedDownloads.archiveDownloadType, '(the ' + '"auto select" archiver options in your EH gallery settings can override this archive type)') extendWithCheckBox(appendRow(controlPanel, 2), 'useAutomatedDownloads-appendIdentifiersEnabled', settings.useAutomatedDownloads.appendIdentifiersEnabled, 'Append identifiers to the filename of every ' + 'archive downloaded by this feature so that other programs can use these to accurately retrieve metadata (' + 'cannot work on some browsers like Google Chrome)') const pageDownloadEnabledRow = extendWithCheckBox(appendRow(controlPanel, 1), 'useAutomatedDownloads-pageDownloadEnabled', settings.useAutomatedDownloads.pageDownloadEnabled, 'Enable the ' + 'one-click page download button, which will automatically start downloads to download all galleries on a ' + 'gallery list page using') extendWithTextInput(pageDownloadEnabledRow, 'useAutomatedDownloads-pageDownloadNumber', settings.useAutomatedDownloads.pageDownloadNumber, 1, 'concurrent gallery downloads per tab', 'Enter an ' + 'integer between 1 and 9, inclusive') extendWithCheckBox(appendRow(controlPanel, 2), 'useAutomatedDownloads-pageRangeDownloadEnabled', settings.useAutomatedDownloads.pageRangeDownloadEnabled, 'Automatically start downloading the next page ' + 'after a page has been fully downloaded in this page download mode, so that all pages in a page range can be ' + 'downloaded in one click') extendWithCheckBox(appendRow(controlPanel, 2), 'useAutomatedDownloads-downloadProtectionEnabled', settings.useAutomatedDownloads.downloadProtectionEnabled, 'Enable download protection in this page download ' + 'mode to prevent accidental interruptions by disabling most links and making galleries open in new tabs') extendWithCheckBox(appendRow(controlPanel, 1), 'useAutomatedDownloads-hideThumbnailEnabled', settings.useAutomatedDownloads.hideThumbnailEnabled, 'Hide each gallery\'s thumbnail cover after it ' + 'has been downloaded in the extended and thumbnail gallery list display modes to make its completion more ' + 'obvious') extendWithCheckBox(appendRow(controlPanel, 1), 'useAutomatedDownloads-downloadAlertsEnabled', settings.useAutomatedDownloads.downloadAlertsEnabled, 'Show error notification popups for download attempts ' + 'that failed due to problems on the site\'s end') // openGalleriesSeparately extendWithCheckBox(appendRow(controlPanel, 0), 'openGalleriesSeparately-featureEnabled', settings.openGalleriesSeparately.featureEnabled, 'Open galleries in new tab by default from all types of ' + 'gallery lists (one option below)') extendWithCheckBox(appendRow(controlPanel, 1), 'openGalleriesSeparately-directMpvEnabled', settings.openGalleriesSeparately.directMpvEnabled, 'Open the MPV directly when you click galleries in ' + 'gallery lists (requires the MPV perk)') // Gallery view features ----------------------------------------------------------------------------------------- controlPanel.insertRow(-1) extendWithStrongText(appendRow(controlPanel, 0), undefined, 'Gallery view features') // addVigilanteLinks extendWithCheckBox(appendRow(controlPanel, 0), 'addVigilanteLinks-featureEnabled', settings.addVigilanteLinks.featureEnabled, 'Add links to the main gallery maintenance threads in the ' + 'vigilante subforum to the gallery information pane') // showAlternativeRating extendWithCheckBox(appendRow(controlPanel, 0), 'showAlternativeRating-featureEnabled', settings.showAlternativeRating.featureEnabled, 'Show a more objective alternative rating system inside ' + 'galleries, which is based on the ratio of times favorited/rated (one option below)') extendWithCheckBox(appendRow(controlPanel, 1), 'showAlternativeRating-hideStarsEnabled', settings.showAlternativeRating.hideStarsEnabled, 'Hide the star rating section inside galleries completely ' + 'and hence disable the ability to give star ratings') // parseExternalLinks extendWithCheckBox(appendRow(controlPanel, 0), 'parseExternalLinks-featureEnabled', settings.parseExternalLinks.featureEnabled, 'Transform URLs to external websites in gallery comments to ' + 'clickable links (potentially risky if you cannot identify malicious links)') // Image view features ------------------------------------------------------------------------------------------- controlPanel.insertRow(-1) extendWithStrongText(appendRow(controlPanel, 0), undefined, 'Image view features') // fitViewerToScreen extendWithCheckBox(appendRow(controlPanel, 0), 'fitViewerToScreen-featureEnabled', settings.fitViewerToScreen.featureEnabled, 'Fit images to screen instead of width in the basic image viewer ' + 'and add a button to temporarily toggle the fit') // fitMpvToScreen extendWithCheckBox(appendRow(controlPanel, 0), 'fitMpvToScreen-featureEnabled', settings.fitMpvToScreen.featureEnabled, 'Fit images to screen instead of width in the MPV and add a button ' + 'to temporarily toggle the fit (requires the MPV perk, a few options below)') extendWithCheckBox(appendRow(controlPanel, 1), 'fitMpvToScreen-makeDefaultEnabled', settings.fitMpvToScreen.makeDefaultEnabled, 'Fit images to screen instead of width by default') extendWithCheckBox(appendRow(controlPanel, 1), 'fitMpvToScreen-mpsModeEnabled', settings.fitMpvToScreen.mpsModeEnabled, 'Use the experimental multi-page spread mode to fit multiple images ' + 'at once where possible, at the cost of mostly breaking MPV navigation methods besides scrolling') extendWithCheckBox(appendRow(controlPanel, 1), 'fitMpvToScreen-seamlessModeEnabled', settings.fitMpvToScreen.seamlessModeEnabled, 'Hide the information and buttons below each main image to make ' + 'the MPV seamless') // hideMpvToolbar extendWithCheckBox(appendRow(controlPanel, 0), 'hideMpvToolbar-featureEnabled', settings.hideMpvToolbar.featureEnabled, 'Hide the vertical toolbar in the MPV, which can rest on top of ' + 'images, and only reveal it on hover (requires the MPV perk)') // removeMpvTooltips extendWithCheckBox(appendRow(controlPanel, 0), 'removeMpvTooltips-featureEnabled', settings.removeMpvTooltips.featureEnabled, 'Remove the filename tooltips on the main images in the MPV ' + '(requires the MPV perk)') // relocateMpvThumbnails extendWithCheckBox(appendRow(controlPanel, 0), 'relocateMpvThumbnails-featureEnabled', settings.relocateMpvThumbnails.featureEnabled, 'Relocate the thumbnail pane and its scroll bar to the right ' + 'side in the MPV, which should be more natural to use (requires the MPV perk)') // Upload management features ------------------------------------------------------------------------------------ controlPanel.insertRow(-1) extendWithStrongText(appendRow(controlPanel, 0), undefined, 'Upload management features') // addGuideLinks extendWithCheckBox(appendRow(controlPanel, 0), 'addGuideLinks-featureEnabled', settings.addGuideLinks.featureEnabled, 'Add links to gallery upload guides to the upload management bar') // Script settings ----------------------------------------------------------------------------------------------- controlPanel.insertRow(-1) extendWithStrongText(appendRow(controlPanel, 0), undefined, 'Script settings') extendWithCheckBox(appendRow(controlPanel, 0), 'script-filterButtonEnabled', settings.script.filterButtonEnabled, 'Show a button next to the gallery list display mode selector to easily ' + 'switch the additional filters feature on/off as a whole without affecting the settings for individual filters') extendWithCheckBox(appendRow(controlPanel, 0), 'script-firefoxCompatibilityEnabled', settings.script.firefoxCompatibilityEnabled, 'Use Firefox compatibility mode to ensure the features that ' + 'load early will always run, at the cost of causing noticeable visual changes when they are loaded') extendWithCheckBox(appendRow(controlPanel, 0), 'script-buttonTooltipEnabled', settings.script.buttonTooltipEnabled, 'Show tooltips on control buttons added by this userscript') return controlPanel } /** * Appends a row to the end of a table element and sets an indent level for the text in this row. * * @param {HTMLTableElement} table - The table element to which a new row will be added. * @param {number} indentLevel - An integer that specifies the level of indent before the text in this row. * @returns {HTMLTableRowElement} The newly created and appended row. */ const appendRow = function (table, indentLevel) { const row = table.insertRow(-1) if (indentLevel > 0) { row.className = `indent${indentLevel}` } return row } /** * Helps createControlPanel() to append an anchor element to a host element as a child node. * * @param {HTMLElement} host - The element under which the anchor element will be added as a child node. * @param {string} [id] - An optional id that can be assigned to the anchor element. * @param {string} text - The visible text content of the anchor element. * @param {string} url - The destination URL of the anchor element. * @param {boolean} bold - Whether or not the visible text will be bold. * @returns {HTMLElement} The element supplied for the "host" parameter above. */ const extendWithAnchor = function (host, id, text, url, bold) { const anchor = document.createElement('a') if (typeof id !== 'undefined') { anchor.id = id } anchor.textContent = text anchor.href = url anchor.onclick = function (anchorEvent) { anchorEvent.preventDefault() window.open(url) } if (bold) { anchor.className = 'boldText' } host.appendChild(anchor) return host } /** * Helps createControlPanel() to append a strong element to a host element as a child node. * * @param {HTMLElement} host - The element under which the strong element will be added as a child node. * @param {string} [id] - An optional id that can be assigned to the strong element. * @param {string} text - The visible text content of the strong element. * @returns {HTMLElement} The element supplied for the "host" parameter above. */ const extendWithStrongText = function (host, id, text) { const strong = document.createElement('strong') if (typeof id !== 'undefined') { strong.id = id } strong.textContent = text host.appendChild(strong) return host } /** * Helps createControlPanel() to append a checkbox input element to a host element as a child node. * * @param {HTMLElement} host - The element under which the checkbox input element will be added as a child node. * @param {string} [id] - An optional id that can be assigned to the checkbox input element. * @param {boolean} checked - Whether the checkbox will appear as ticked by default. * @param {string} [label] - An optional text label that will immediately follow this checkbox input element. * @returns {HTMLElement} The element supplied for the "host" parameter above. */ const extendWithCheckBox = function (host, id, checked, label) { const box = document.createElement('input') box.type = 'checkbox' if (typeof id !== 'undefined') { box.id = id } if (checked) { box.checked = 'checked' } host.appendChild(box) if (typeof label !== 'undefined') { addLabel(host, id, label) } return host } /** * Helps createControlPanel() to append a text input element to a host element as a child node. * * @param {HTMLElement} host - The element under which the text input element will be added as a child node. * @param {string} [id] - An optional id that can be assigned to the text input element. * @param {string} defaultValue - The text that will appear inside the text input by default. * @param {number} length - An integer that specifies the maximum number of characters this text input will accept. * @param {string} [label] - An optional text label that will immediately follow this text input element. * @param {string} tooltip - A tooltip to be set on this text input. * @returns {HTMLElement} The element supplied for the "host" parameter above. */ const extendWithTextInput = function (host, id, defaultValue, length, label, tooltip) { const input = document.createElement('input') input.type = 'text' if (typeof id !== 'undefined') { input.id = id } input.value = defaultValue // The width of this text input matches the "length" paramter, but is limited to a maximum of 30ch. input.style.width = `${Math.min(length, 30)}ch` input.maxLength = length // The tooltip is compulsory since at least the character limit for this input needs to be shown. setTooltip(input, tooltip) host.appendChild(input) if (typeof label !== 'undefined') { addLabel(host, id, label) } return host } /** * Helps createControlPanel() to append a select element to a host element as a child node. * * @param {HTMLElement} host - The element under which the select element will be added as a child node. * @param {string} [id] - An optional id that can be assigned to the select element. * @param {string} defaultOption - The option that will be selected and visible inside this selector by default. * @param {string[]} optionList - The array of options which can be selected under this select element. * @param {string} [label] - An optional text label that will immediately follow this select element. * @returns {HTMLElement} The element supplied for the "host" parameter above. */ const extendWithOptionSelector = function (host, id, defaultOption, optionList, label) { const select = document.createElement('select') if (typeof id !== 'undefined') { select.id = id } for (const optionText of optionList) { const option = document.createElement('option') option.value = optionText option.textContent = optionText if (optionText === defaultOption) { option.setAttribute('selected', 'selected') } select.appendChild(option) } host.appendChild(select) if (typeof label !== 'undefined') { addLabel(host, id, label) } return host } /** * Helps other functions to add a label to an element. * * @param {HTMLElement} host - The element under which the label will be added as a child node. * @param {string} forId - The id of the element after which the label will be shown and to which it will bind. * @param {string} text - The text content of the label. */ const addLabel = function (host, forId, text) { const label = document.createElement('label') label.setAttribute('for', forId) label.textContent = text host.appendChild(label) } /** * Converts a string array into a comma-separated string, or simply returns the argument if it is not an array. * * @param {(string[]|string)} potentialArray - A string array to be joined or a simple string. * @returns {string} A comma-separated string if a string array was provided; otherwise the same string that was * provided for "potentialArray". */ const joinPotentialArray = function (potentialArray) { // This function is only used in one way, where array arguments will all have strings, so it does not need to // check whether the array provided actually contains strings. if (Array.isArray(potentialArray)) { return potentialArray.join(', ') } else { return potentialArray } } /** * Checks all user inputs for the settings in the control panel and saves the new settings to storage. * * The "settings" variable and the userscript storage will only be updated after all inputs have been confirmed to * be valid and formatted for storage, so nothing will be done if there is an invalid input. * * @returns {boolean} true if inputs for all settings have been checked and saved without any problem; otherwise * false if any of the inputs is invalid for its target setting. */ const saveSettings = function () { // Check the inputs and put them in temporary storage first. const inputs = {} for (const feature of Object.keys(settings)) { for (const setting of Object.keys(settings[feature])) { const settingId = `${feature}-${setting}` if (settingId === 'script-version') { inputs[settingId] = api.info.script.version continue } const settingElement = document.getElementById(settingId) if (settingElement.type === 'checkbox') { inputs[settingId] = settingElement.checked } else if (settingElement.type === 'text') { inputs[settingId] = checkTextInput(settingElement.value.trim(), settingId) // The falsy return values can be null or '' and the check needs to be specific. if (inputs[settingId] === null) { return false } } else if (settingElement.type === 'select-one') { inputs[settingId] = settingElement.value } } } // After all inputs have been confirmed to be valid and formatted appropriately, save the inputs to the "settings" // variable first. This also ensures that the control panel will show updated settings if it is opened again // before the page is reloaded. for (const feature of Object.keys(settings)) { for (const setting of Object.keys(settings[feature])) { const settingId = `${feature}-${setting}` settings[feature][setting] = inputs[settingId] } } // Save the updated settings to the userscript storage in JSON format. api.setValue('settings', JSON.stringify(settings)) return true } /** * Checks whether or not a text input is appropriate for its target setting, and formats it for storage. * * @param {string} input - The raw text input entered by the user. * @param {string} settingId - The id of the control panel element in which this text input was entered. * @returns {(string|string[]|number[]|null)} A formatted version of the text input for storage, or '' if the * input is for a disabled setting and empty, or null if the input is invalid for the target setting. */ const checkTextInput = function (input, settingId) { // Always require and save valid text inputs except for empty inputs for disabled settings, so that it is easy to // switch settings on/off. input = input.trim() switch (settingId) { case 'applyAdditionalFilters-ratedFilterStars': if (input.toLowerCase() === 'all') { return 'all' } else { const validNumbers = [0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5] const numbers = checkTextInputList(input, settingId, 'applyAdditionalFilters-ratedFilterEnabled', number => +number, number => validNumbers.includes(number)) if (Array.isArray(numbers)) { return numbers.sort((a, b) => a - b) } else { return numbers } } case 'applyAdditionalFilters-favoritedFilterCategories': if (input.toLowerCase() === 'all') { return 'all' } else { return checkTextInputList(input, settingId, 'applyAdditionalFilters-favoritedFilterEnabled', category => category.toLowerCase(), category => category.length <= 20) } case 'applyAdditionalFilters-titleFilterKeywords': if (document.getElementById('applyAdditionalFilters-titleFilterType').value === 'one of the keywords') { return checkTextInputList(input, settingId, 'applyAdditionalFilters-titleFilterEnabled', keyword => keyword.toLowerCase(), undefined) } else { return checkTextInputRegex(input, settingId, 'applyAdditionalFilters-titleFilterEnabled') } case 'applyTextFilters-commentatorFilterUsernames': // Usernames are not converted to lowercase. return checkTextInputList(input, settingId, 'applyTextFilters-commentatorFilterEnabled', undefined, undefined) case 'applyTextFilters-commentFilterKeywords': return checkTextInputList(input, settingId, 'applyTextFilters-commentFilterEnabled', keyword => keyword.toLowerCase(), undefined) case 'applyTextFilters-posterFilterUsernames': // Usernames are not converted to lowercase. return checkTextInputList(input, settingId, 'applyTextFilters-posterFilterEnabled', undefined, undefined) case 'applyTextFilters-postFilterKeywords': return checkTextInputList(input, settingId, 'applyTextFilters-postFilterEnabled', keyword => keyword.toLowerCase(), undefined) case 'useAutomatedDownloads-minimumSeedNumber': return checkTextInputInteger(input, settingId, 'useAutomatedDownloads-torrentDownloadEnabled', /^[1-9]$/) case 'useAutomatedDownloads-ignoreRequirementsSize': return checkTextInputInteger(input, settingId, 'useAutomatedDownloads-torrentDownloadEnabled', /^\d+$/) case 'useAutomatedDownloads-pageDownloadNumber': return checkTextInputInteger(input, settingId, 'useAutomatedDownloads-pageDownloadEnabled', /^[1-9]$/) } } /** * Helps checkTextInput() to check and format a text input which can potentially include a comma-separated list. * * @param {string} input - The raw text input entered by the user. * @param {string} settingId - The id of the control panel input element in which this text input was entered. * @param {string} prerequisiteId - The id of the control panel checkbox element preceding this input element. * @param {Function} [conversionFunction] - An optional anonymous function to be applied to each item in the * comma-separated list to format it for storage and further custom testing by the "testFunction" below. * @param {Function} [testFunction] - An optional anonymous function with a boolean return value to be applied to * every item in the comma-separated list. For this text input to be considered valid, every item in the list must * pass this test with a true return value. * @returns {(string[]|number[]|string|null)} A formatted version of the text input for storage, or '' if the input * is for a disabled setting and empty, or null if the input is invalid for the target setting. */ const checkTextInputList = function (input, settingId, prerequisiteId, conversionFunction, testFunction) { const settingPath = settingId.split('-') const featureId = `${settingPath[0]}-featureEnabled` // Full-width comma is supported. The filter() will handle /^.*?,+.*?$/ cases. let values = input.split(/[,,]/).map(value => value.trim()).filter(value => value.length > 0) if (values.length === 0) { // Empty input is only accepted when this (sub)feature or its parent feature is disabled. if (document.getElementById(featureId).checked && document.getElementById(prerequisiteId).checked) { alert(messages[settingPath[0]][settingPath[1]].emptyInputError) return null } else { return '' } } else { if (typeof conversionFunction !== 'undefined') { values = values.map(conversionFunction) } if (typeof testFunction !== 'undefined') { if (values.every(testFunction)) { return values } else { alert(messages[settingPath[0]][settingPath[1]].invalidInputError) return null } } else { return values } } } /** * Helps checkTextInput() to check and format a text input which should only contain an integer. * * @param {string} input - The raw text input entered by the user. * @param {string} settingId - The id of the control panel input element in which this text input was entered. * @param {string} prerequisiteId - The id of the control panel checkbox element preceding this input element. * @param {RegExp} testRegex - A RegExp object against which the text input will be tested. For this text input to * be considered valid, it must match this RegExp. * @returns {(number|string|null)} An integer number for storage, or '' if the input is for a disabled setting and * empty, or null if the input is invalid for the target setting. */ const checkTextInputInteger = function (input, settingId, prerequisiteId, testRegex) { const settingPath = settingId.split('-') if (input === '') { if (document.getElementById(prerequisiteId).checked) { alert(messages[settingPath[0]][settingPath[1]].emptyInputError) return null } else { return '' } } else { if (testRegex.test(input)) { return +input } else { alert(messages[settingPath[0]][settingPath[1]].invalidInputError) return null } } } /** * Helps checkTextInput() to check and format a text input which should contain a regular expression. * * @param {string} input - The raw text input entered by the user. * @param {string} settingId - The id of the control panel input element in which this text input was entered. * @param {string} prerequisiteId - The id of the control panel checkbox element preceding this input element. * @returns {(string|null)} A regular expression saved as a string for storage, or '' if the input is for a disabled * setting and empty, or null if the input is invalid for the target setting. */ const checkTextInputRegex = function (input, settingId, prerequisiteId) { const settingPath = settingId.split('-') // Check for empty input and inputs that only contain a number of "/". if (/^\/*$/.test(input)) { if (document.getElementById(prerequisiteId).checked) { alert(messages[settingPath[0]][settingPath[1]].emptyInputError) return null } else { return '' } } else { // Check for and remove possible forward slashes enclosing the regular expression. const pattern = input.match(/^\/(.*)\/$/) if (pattern !== null) { return pattern[1] } else { return input } } } /** * Closes and destroys the control panel without checking anything and restores the visibility of the gallery list. */ const closeControlPanel = function () { const controlPanel = document.getElementById('controlPanel') controlPanel.parentNode.removeChild(controlPanel) galleryList.style.removeProperty('display') document.getElementById('openConfigButton').style.display = 'unset' document.getElementById('saveConfigButton').style.display = 'none' document.getElementById('cancelConfigButton').style.display = 'none' } /** * Adds a button next to the gallery list display mode selector to swtich the additional filters feature on/off. */ const addFilterButton = function () { // The additional filters cannot run on the gallery toplists, so there is no need to add the button. if (windowUrl.includes('toplist.php')) { return } let additionalFiltersButton if (settings.applyAdditionalFilters.featureEnabled) { additionalFiltersButton = createDmsButton('additionalFiltersButton', 'Disable Additional Filters', toggleAdditionalFilters, `Turn off all additional filters from ${api.info.script.name} and refresh the ` + 'page to reload the gallery list') } else { additionalFiltersButton = createDmsButton('additionalFiltersButton', 'Enable Additional Filters', toggleAdditionalFilters, `Turn on the individual additional filters from ${api.info.script.name} which ` + 'have been enabled in the control panel, and refresh the page to filter the gallery list') } const dmsHost = document.querySelector('.searchnav > div:last-child') dmsHost.insertBefore(additionalFiltersButton, dmsHost.firstChild) } /** * Toggles the additional filters feature in the settings but not its subfeatures, and reloads the page. * * @type {clickEventHandler} * @param {MouseEvent} clickEvent - The event object passed to this event handler on click. */ const toggleAdditionalFilters = async function (clickEvent) { settings.applyAdditionalFilters.featureEnabled = !settings.applyAdditionalFilters.featureEnabled await api.setValue('settings', JSON.stringify(settings)) window.location.reload() } addConfigButtons() // The filter button has to be added here because it needs to run when the additional filters feature is disabled. settings.script.filterButtonEnabled && addFilterButton() } /** * Adds buttons to automatically download galleries or whole gallery lists directly from all types of gallery lists. * * While this function runs on gallery toplists, page download is not supported there due to the inconsistent format * used by these toplists compared to the standard gallery lists. */ const useAutomatedDownloads = function () { /** * A callback function that runs on the document returned by an XHR to complete a step in a gallery download chain. * * @callback onloadFunction * @param {HTMLDivElement} galleryDownloadButton - The gallery download button associated with this download chain. * @param {Document} documentReceived - The parsed document returned by the last XHR. * @param {Object} [responseReceived] - The response from the last XHR, which is only required to start a download. */ // This feature will not run if there is no gallery to download. if (pageType !== 'gallery list' || !document.querySelector('.glink')) { return } const shortcuts = settings.useAutomatedDownloads const errors = messages.useAutomatedDownloads.runtime const domParser = new DOMParser() let inPageDownloadMode = false let originalTitle // This counter is used to ensure the "pageDownloadNumber" setting can be strictly enforced at all times. let concurrentDownloadsRunning = 0 /** * Adds CSS styles to support download buttons added by this feature. * * In general, "cursor" and "opacity" need "!important" to override the properties on the popular list and all * gallery toplists, which already have "!important". */ const addShortcutStyles = function () { let downloadShortcutStyles = ` .cs, .cn { position: relative; } .galleryDownloadButton { position: absolute; top: 0; left: 0; box-shadow: none; transition-duration: 0.3s; } .galleryDownloadButton.idle { background-color: #22A7F0; opacity: 0 !important; cursor: pointer !important; } .galleryDownloadButton.loading, .galleryDownloadButton.downloading { background-color: #F7CA18; cursor: pointer !important; } .galleryDownloadButton.done { background-color: #000000; cursor: default !important; } .galleryDownloadButton.failed, .galleryDownloadButton.unavailable { background-color: #D91E18; cursor: pointer !important; } .galleryDownloadButton.idle:hover { box-shadow: 0 1px 7px 2px rgba(34, 167, 240, 0.6); } .galleryDownloadButton.failed:hover, .galleryDownloadButton.unavailable:hover { box-shadow: 0 1px 7px 2px rgba(217, 30, 24, 0.6); } #pageDownloadButton { width: 155px; }` // Add borders to match the category buttons in three of the four possible cases. The only scenario where borders // are not needed is the original EX without the light theme feature. if (windowUrl.includes('e-hentai.org') || settings.applyLightTheme.featureEnabled) { downloadShortcutStyles += ` .galleryDownloadButton { margin: -1px; }` if (windowUrl.includes('e-hentai.org') && settings.applyDarkTheme.featureEnabled) { // The border will use the same colour as the button to hide itself and stay consistent with the theme. downloadShortcutStyles += ` .galleryDownloadButton.idle { border: 1px solid #22A7F0; } .galleryDownloadButton.loading, .galleryDownloadButton.downloading { border: 1px solid #F7CA18; } .galleryDownloadButton.done { border: 1px solid #000000; } .galleryDownloadButton.failed, .galleryDownloadButton.unavailable { border: 1px solid #D91E18; }` } else { // In the other two cases, the HSV brightness of each button colour is reduced by 20 to derive the // corresponding border colour. downloadShortcutStyles += ` .galleryDownloadButton.idle { border: 1px solid #1A84BD; } .galleryDownloadButton.loading, .galleryDownloadButton.downloading { border: 1px solid #C4A114; } .galleryDownloadButton.done { border: 1px solid #000000; } .galleryDownloadButton.failed, .galleryDownloadButton.unavailable { border: 1px solid #A61712; }` } } // Set the hover behaviour and button size in each display mode. switch (displayMode) { case 'minimal': case 'minimal+': downloadShortcutStyles += ` tr:hover .galleryDownloadButton { display: inline-block; opacity: 1 !important; } .cs:not([data-disabled]):hover { opacity: 1 !important; }` break case 'compact': downloadShortcutStyles += ` tr:hover .galleryDownloadButton { display: inline-block; opacity: 1 !important; } .cn:not([data-disabled]):hover { opacity: 1 !important; }` break case 'extended': downloadShortcutStyles += ` .galleryDownloadButton { width: 110px; } tr:hover .galleryDownloadButton { display: inline-block; opacity: 1 !important; } .cn:not([data-disabled]):hover { opacity: 1 !important; }` break case 'thumbnail': // Button width needs to override an incorrect default width for ".itg .cs" in this display mode only. downloadShortcutStyles += ` .galleryDownloadButton { height: 18px; width: 110px !important; line-height: 18px; } .gl1t:hover .galleryDownloadButton { display: inline-block; opacity: 1 !important; } .cs:not([data-disabled]):hover { opacity: 1 !important; }` } appendStyleText(document.head, 'downloadShortcutStyles', downloadShortcutStyles) } /** * Adds all gallery download buttons and also the page download button to the gallery list. */ const addDownloadButtons = function () { let bases let galleries let thumbnails switch (displayMode) { case 'minimal': case 'minimal+': bases = document.querySelectorAll('.gl1m.glcat > .cs') galleries = document.querySelectorAll('.gl3m.glname > a') break case 'compact': bases = document.querySelectorAll('.gl1c.glcat > .cn') galleries = document.querySelectorAll('.gl3c.glname > a') break case 'extended': bases = document.querySelectorAll('.gl3e > .cn') galleries = document.querySelectorAll('.gl2e > div > a') thumbnails = document.querySelectorAll('.gl1e > div > a') break case 'thumbnail': bases = document.querySelectorAll('.gl5t > div > .cs') // In this display mode, the anchors on gallery titles are located differently in the DOM tree between the // search index and the favorite list, but the anchors on gallery thumbnails are not. Therefore, one selector // can be safely used for both page types. The same set of anchors from this selector can also be used to // acquire both the gallery addresses and the thumbnail images below. galleries = document.querySelectorAll('.gl3t > a') thumbnails = document.querySelectorAll('.gl3t > a') } const torrents = document.querySelectorAll('.gldown') const timestamps = document.querySelectorAll('div[id ^= "posted_"]') let i = bases.length while (i--) { const galleryDownloadButton = document.createElement('div') if (displayMode === 'compact' || displayMode === 'extended') { galleryDownloadButton.className = 'cn galleryDownloadButton idle' } else { galleryDownloadButton.className = 'cs galleryDownloadButton idle' } galleryDownloadButton.textContent = 'Download' galleryDownloadButton.gallery = galleries[i].href if (typeof thumbnails !== 'undefined') { galleryDownloadButton.dataThumbnail = thumbnails[i] } // If there is indeed a torrent, the child will be "A"; otherwise it will be "IMG". if (shortcuts.torrentDownloadEnabled && torrents[i].firstElementChild.nodeName === 'A') { // Add torrent information to the button when torrent download is enabled and also available for this gallery. galleryDownloadButton.torrent = torrents[i].firstElementChild.href } else if (!shortcuts.archiveDownloadEnabled) { // When archive download is disabled, a button will not be added if torrent download is not possible. continue } galleryDownloadButton.timestamp = Date.parse(timestamps[i].textContent) galleryDownloadButton.addEventListener('click', handleGalleryDownload) bases[i].removeAttribute('onclick') bases[i].appendChild(galleryDownloadButton) } // Like the control panel button, the page download button is not added on toplists because of the legacy format. // Page download is most likely not needed there anyway. if (shortcuts.pageDownloadEnabled && !windowUrl.includes('toplist.php')) { originalTitle = document.title // Add a button after the control panel button or the favourite sorting selector to download one or more pages // in one click. const pageDownloadButton = createDmsButton('pageDownloadButton', '', attemptPageDownload, '') document.querySelector('.searchnav > div:first-child').appendChild(pageDownloadButton) changePageDownloadState('idle') } } /** * Checks the state of a gallery download button when it is clicked and decides what will happen. * * @type {clickEventHandler} * @param {MouseEvent} clickEvent - The event object passed to this event handler on click. */ const handleGalleryDownload = function (clickEvent) { const galleryDownloadButton = clickEvent.target if (/loading|downloading/.test(galleryDownloadButton.className)) { // Cancel the attempt if the button is clicked while it is loading or downloading. changeGalleryDownloadState(galleryDownloadButton, 'idle') } else if (galleryDownloadButton.className.includes('done')) { // Do nothing if the download attempt has already been successful. This onclick function is not removed, because // this button will be reverted to idle if the user cancel an archive file download before it completes. } else { // If the button state is "idle", "unavailable" or "failed": changeGalleryDownloadState(galleryDownloadButton, 'loading') if (typeof galleryDownloadButton.torrent !== 'undefined') { attemptDownloadStep(galleryDownloadButton, galleryDownloadButton.torrent, selectTargetTorrent) } else { // Stop the archive download attempt right away if GM.download() is not available. if (!shortcuts.archiveDownloadType.includes('H@H') && typeof api.download === 'undefined') { handleError(galleryDownloadButton, 'gmDownloadNotSupportedError') } else { attemptDownloadStep(galleryDownloadButton, galleryDownloadButton.gallery, passGalleryView) } } } } // Common functions in all download chains /** * Uses GET or POST to request a page via XHR, and runs the supplied function on load. * * @param {HTMLDivElement} galleryDownloadButton - The gallery download button associated with this attempt. * @param {string} targetUrl - The URL to which this XHR will be sent. * @param {onloadFunction} onloadFunction - The function that will run on the successful response from this XHR. * @param {string} [formData] - The form data to be submitted via POST. */ const attemptDownloadStep = function (galleryDownloadButton, targetUrl, onloadFunction, formData) { // Check whether the button is in the "loading" state, which is the only state where this function should run. If // the user has clicked the gallery download button to cancel the download when it is still loading, the next // download step in the chain should not be attempted. Therefore, the state is checked below to prevent this // function from running and stop the chain when the state is not right. This allows the user to abort a download // attempt when another step is running between the completion of the last XHR and the start of the next XHR. if (!galleryDownloadButton.className.includes('loading')) { return } const xhrDetails = { synchronous: false, timeout: 30000, url: targetUrl, onload: function (response) { const documentReceived = domParser.parseFromString(response.responseText, 'text/html') if (response.status === 200) { // At least "unavailableTorrentError" comes with status code 200, so a check is needed for the specific // onloadFunction. My old comments say "unavailableArchiverError" might also come with status code 200, but // this cannot be confirmed these days and is thus not handled below. if (onloadFunction.name === 'downloadTorrent' && checkErrorMessage(documentReceived.body.textContent) === 'unavailableTorrentError') { handleError(galleryDownloadButton, checkErrorMessage(documentReceived.body.textContent), documentReceived.documentElement.outerHTML) } else { onloadFunction(galleryDownloadButton, documentReceived, response) } } else if (response.status === 404) { handleError(galleryDownloadButton, 'unavailableGalleryError') } else { handleError(galleryDownloadButton, checkErrorMessage(documentReceived.body.textContent), documentReceived.documentElement.outerHTML) } }, ontimeout: function (response) { // Retry this step after the 30s timeout, until the user aborts this download attempt using the button. attemptDownloadStep(galleryDownloadButton, targetUrl, onloadFunction, formData) } } if (typeof formData === 'undefined') { xhrDetails.method = 'GET' } else { xhrDetails.method = 'POST' xhrDetails.headers = { 'Content-Type': 'application/x-www-form-urlencoded' } xhrDetails.data = formData } const errorHandler = function (runtimeError) { if (Object.keys(runtimeError).length === 0) { // An empty error object will be thrown when a file processing page is too slow to load. This is simply // ignored to let the XHR finish loading this page. } else if (typeof runtimeError.error !== 'undefined') { if (runtimeError.error.includes('Request was blocked by the user')) { handleError(galleryDownloadButton, 'crossOriginNotAllowedError') } else { handleError(galleryDownloadButton, 'unknownError') } } else { // Other errors should be network errors. handleError(galleryDownloadButton, 'networkError') } } if (api.version === 'v4') { galleryDownloadButton.xhr = api.xmlHttpRequest(xhrDetails) galleryDownloadButton.xhr.catch(errorHandler) } else { xhrDetails.onerrror = errorHandler galleryDownloadButton.xhr = api.xmlHttpRequest(xhrDetails) } } /** * Checks the status of an XHR to a download address and decides how to proceed without loading the whole response. * * @param {HTMLDivElement} galleryDownloadButton - The gallery download button associated with this attempt. * @param {string} downloadUrl - The URL to which this XHR will be sent to request the file download for testing. * @param {onloadFunction} onloadFunction - The function that will run when this XHR detects a file download. */ const testDownloadHeaders = function (galleryDownloadButton, downloadUrl, onloadFunction) { // Check the state of the gallery download button for the same reason as in attemptDownloadStep(). if (!galleryDownloadButton.className.includes('loading')) { return } const xhrDetails = { method: 'GET', synchronous: false, timeout: 30000, url: downloadUrl, onreadystatechange: function (response) { if (response.readyState === 2) { // This is the "HEADERS_RECEIVED" ready state, and headers can now be checked to see if a file or HTML page // is being serverd from this download address. if (response.responseHeaders.includes('content-type: text/html')) { // If "content-type" is "text/html; charset=UTF-8", a file download is not being served, so there is an // error. In this case, this test XHR is not aborted, and instead it will complete like a normal XHR to // handle the error on load in the "response.readyState === 4" case below. } else { // When a file download is being served, let this test XHR abort itself to prevent it from downloading the // whole response, and start the actual file download using the supplied function. galleryDownloadButton.xhr.abort() onloadFunction(galleryDownloadButton, undefined, response) } } if (response.readyState === 4) { // If the XHR is not aborted and reaches the "load" ready state, there must be an application error. const documentReceived = domParser.parseFromString(response.responseText, 'text/html') handleError(galleryDownloadButton, checkErrorMessage(documentReceived.body.textContent), documentReceived.documentElement.outerHTML) } }, ontimeout: function (response) { // Retry this step after the 30s timeout, until the user aborts this download attempt using the button. testDownloadHeaders(galleryDownloadButton, downloadUrl, onloadFunction) } } const errorHandler = function (runtimeError) { if (runtimeError.error) { handleError(galleryDownloadButton, 'unknownError') // This function runs at the final file download stage, so it should not need to check for // "crossOriginNotAllowedError", because this error would have happened earlier in the chain before this // function can be reached, namely at the download ready stage. } else { // Other errors should be network errors. handleError(galleryDownloadButton, 'networkError') } } if (api.version === 'v4') { galleryDownloadButton.xhr = api.xmlHttpRequest(xhrDetails) galleryDownloadButton.xhr.catch(errorHandler) } else { xhrDetails.onerrror = errorHandler galleryDownloadButton.xhr = api.xmlHttpRequest(xhrDetails) } } /** * Downloads a file, which could be an archive or a torrent, using the reliable GM.download() method. * * This function assumes that a testing step has been completed before it to check for application errors. Also, * unlike any other function in this script, this function effectively only supports Tampermonkey. * * @param {HTMLDivElement} galleryDownloadButton - The gallery download button associated with this attempt. * @param {string} downloadUrl - The URL to which this XHR will be sent to request the file download. * @param {string} filename - The full filename for the file to be downloaded and saved. */ const downloadUsingApi = function (galleryDownloadButton, downloadUrl, filename) { if (typeof api.download === 'undefined') { handleError(galleryDownloadButton, 'gmDownloadNotSupportedError') return } // Replace illegal characters in the filename with their legal, full-width versions to better preserve the gallery // title. This should not apply to Chromium browsers because they always respect the filename from the response // headers, but still helps. Some of these illegal characters like "/" are already automatically converted to // spaces by the site in the headers, while others like ":" are not. let formattedName = replaceIllegalCharacters(filename) // The filename change below will not work on Chromium browsers because they always respect the response headers. const extensionSplit = formattedName.match(/(.+?)(\.zip|\.torrent)$/) if (extensionSplit[2] === '.zip' && shortcuts.appendIdentifiersEnabled) { const identifiers = galleryDownloadButton.gallery.match(/e(?:-|x)hentai\.org\/g\/(\d+)\/([0-9a-z]+)/) formattedName = `${extensionSplit[1]} [GID ${identifiers[1]} GT ${identifiers[2]}]${extensionSplit[2]}` } const xhrDetails = { url: downloadUrl, name: formattedName, saveAs: false, timeout: 30000, onload: function (response) { // The button will only move to the "done" state when the file download is finished. changeGalleryDownloadState(galleryDownloadButton, 'done') }, ontimeout: function (response) { // Retry the download after the 30s timeout, until the user aborts this download attempt using the button. downloadUsingApi(galleryDownloadButton, downloadUrl, filename) }, onabort: function (response) { changeGalleryDownloadState(galleryDownloadButton, 'idle') } } changeGalleryDownloadState(galleryDownloadButton, 'downloading') // There is no if branch for adding xhrDetails.onerror, since this function does not support GM API v3. galleryDownloadButton.xhr = api.download(xhrDetails) galleryDownloadButton.xhr.catch(function (runtimeError) { switch (runtimeError.error) { case 'not_enabled': case 'not_permitted': handleError(galleryDownloadButton, 'gmDownloadNotEnabledError') break case 'not_whitelisted': handleError(galleryDownloadButton, 'gmDownloadFileExtensionError') break case 'not_supported': handleError(galleryDownloadButton, 'gmDownloadNotSupportedError') break case 'not_succeeded': if (typeof runtimeError.details === 'undefined' || runtimeError.details.current === 'USER_CANCELED') { // Reset the button state when the user cancels the file download in the browser before it is completed. // The details property does not exist on Firefox, at least in this case. changeGalleryDownloadState(galleryDownloadButton, 'idle') } else if (runtimeError.details.current === 'NETWORK_FAILED') { handleError(galleryDownloadButton, 'networkError') } else if (runtimeError.details.current === 'SERVER_FAILED') { // The test-and-download process will be tried again when some server problem breaks the download. if (filename.includes('.torrent')) { attemptDownloadStep(galleryDownloadButton, downloadUrl, downloadTorrent) } else { testDownloadHeaders(galleryDownloadButton, downloadUrl, downloadArchive) } } break case 'filename must not contain illegal characters': handleError(galleryDownloadButton, 'illegalFilenameError') } }) } /** * Helps downloadUsingApi() to replace illegal characters in a filename with their full-width versions. * * The illegal characters are taken from the wikipedia page on filename except for tilde. Tilde is allowed in * filenames, but needs to be replaced because it can stop GM.download() from download files on Chromium browsers. * * @param {string} filename - The filename string to be checked and potentially formatted. */ const replaceIllegalCharacters = function (filename) { const fullWidthReplacements = { '/': '/', '\\\\': '\', '\\?': '?', '%': '%', '\\*': '*', ':': ':', '\\|': '|', '"': '"', '<': '<', '>': '>', '~': '~', '\\s': ' ', // JavaScript does not support regex POSIX classes, so a huge list from https://stackoverflow.com/a/11598864 has // to be used instead to remove control codes. ['[\0-\x1F\x7F-\x9F\xAD\u0378\u0379\u037F-\u0383\u038B\u038D\u03A2\u0528-\u0530\u0557\u0558\u0560\u0588' + '\u058B-\u058E\u0590\u05C8-\u05CF\u05EB-\u05EF\u05F5-\u0605\u061C\u061D\u06DD\u070E\u070F\u074B\u074C' + '\u07B2-\u07BF\u07FB-\u07FF\u082E\u082F\u083F\u085C\u085D\u085F-\u089F\u08A1\u08AD-\u08E3\u08FF\u0978\u0980' + '\u0984\u098D\u098E\u0991\u0992\u09A9\u09B1\u09B3-\u09B5\u09BA\u09BB\u09C5\u09C6\u09C9\u09CA\u09CF-\u09D6' + '\u09D8-\u09DB\u09DE\u09E4\u09E5\u09FC-\u0A00\u0A04\u0A0B-\u0A0E\u0A11\u0A12\u0A29\u0A31\u0A34\u0A37\u0A3A' + '\u0A3B\u0A3D\u0A43-\u0A46\u0A49\u0A4A\u0A4E-\u0A50\u0A52-\u0A58\u0A5D\u0A5F-\u0A65\u0A76-\u0A80\u0A84' + '\u0A8E\u0A92\u0AA9\u0AB1\u0AB4\u0ABA\u0ABB\u0AC6\u0ACA\u0ACE\u0ACF\u0AD1-\u0ADF\u0AE4\u0AE5\u0AF2-\u0B00' + '\u0B04\u0B0D\u0B0E\u0B11\u0B12\u0B29\u0B31\u0B34\u0B3A\u0B3B\u0B45\u0B46\u0B49\u0B4A\u0B4E-\u0B55\u0B58-' + '\u0B5B\u0B5E\u0B64\u0B65\u0B78-\u0B81\u0B84\u0B8B-\u0B8D\u0B91\u0B96-\u0B98\u0B9B\u0B9D\u0BA0-\u0BA2' + '\u0BA5-\u0BA7\u0BAB-\u0BAD\u0BBA-\u0BBD\u0BC3-\u0BC5\u0BC9\u0BCE\u0BCF\u0BD1-\u0BD6\u0BD8-\u0BE5\u0BFB-' + '\u0C00\u0C04\u0C0D\u0C11\u0C29\u0C34\u0C3A-\u0C3C\u0C45\u0C49\u0C4E-\u0C54\u0C57\u0C5A-\u0C5F\u0C64\u0C65' + '\u0C70-\u0C77\u0C80\u0C81\u0C84\u0C8D\u0C91\u0CA9\u0CB4\u0CBA\u0CBB\u0CC5\u0CC9\u0CCE-\u0CD4\u0CD7-\u0CDD' + '\u0CDF\u0CE4\u0CE5\u0CF0\u0CF3-\u0D01\u0D04\u0D0D\u0D11\u0D3B\u0D3C\u0D45\u0D49\u0D4F-\u0D56\u0D58-\u0D5F' + '\u0D64\u0D65\u0D76-\u0D78\u0D80\u0D81\u0D84\u0D97-\u0D99\u0DB2\u0DBC\u0DBE\u0DBF\u0DC7-\u0DC9\u0DCB-\u0DCE' + '\u0DD5\u0DD7\u0DE0-\u0DF1\u0DF5-\u0E00\u0E3B-\u0E3E\u0E5C-\u0E80\u0E83\u0E85\u0E86\u0E89\u0E8B\u0E8C' + '\u0E8E-\u0E93\u0E98\u0EA0\u0EA4\u0EA6\u0EA8\u0EA9\u0EAC\u0EBA\u0EBE\u0EBF\u0EC5\u0EC7\u0ECE\u0ECF\u0EDA' + '\u0EDB\u0EE0-\u0EFF\u0F48\u0F6D-\u0F70\u0F98\u0FBD\u0FCD\u0FDB-\u0FFF\u10C6\u10C8-\u10CC\u10CE\u10CF\u1249' + '\u124E\u124F\u1257\u1259\u125E\u125F\u1289\u128E\u128F\u12B1\u12B6\u12B7\u12BF\u12C1\u12C6\u12C7\u12D7' + '\u1311\u1316\u1317\u135B\u135C\u137D-\u137F\u139A-\u139F\u13F5-\u13FF\u169D-\u169F\u16F1-\u16FF\u170D' + '\u1715-\u171F\u1737-\u173F\u1754-\u175F\u176D\u1771\u1774-\u177F\u17DE\u17DF\u17EA-\u17EF\u17FA-\u17FF' + '\u180F\u181A-\u181F\u1878-\u187F\u18AB-\u18AF\u18F6-\u18FF\u191D-\u191F\u192C-\u192F\u193C-\u193F\u1941-' + '\u1943\u196E\u196F\u1975-\u197F\u19AC-\u19AF\u19CA-\u19CF\u19DB-\u19DD\u1A1C\u1A1D\u1A5F\u1A7D\u1A7E' + '\u1A8A-\u1A8F\u1A9A-\u1A9F\u1AAE-\u1AFF\u1B4C-\u1B4F\u1B7D-\u1B7F\u1BF4-\u1BFB\u1C38-\u1C3A\u1C4A-\u1C4C' + '\u1C80-\u1CBF\u1CC8-\u1CCF\u1CF7-\u1CFF\u1DE7-\u1DFB\u1F16\u1F17\u1F1E\u1F1F\u1F46\u1F47\u1F4E\u1F4F\u1F58' + '\u1F5A\u1F5C\u1F5E\u1F7E\u1F7F\u1FB5\u1FC5\u1FD4\u1FD5\u1FDC\u1FF0\u1FF1\u1FF5\u1FFF\u200B-\u200F\u202A-' + '\u202E\u2060-\u206F\u2072\u2073\u208F\u209D-\u209F\u20BB-\u20CF\u20F1-\u20FF\u218A-\u218F\u23F4-\u23FF' + '\u2427-\u243F\u244B-\u245F\u2700\u2B4D-\u2B4F\u2B5A-\u2BFF\u2C2F\u2C5F\u2CF4-\u2CF8\u2D26\u2D28-\u2D2C' + '\u2D2E\u2D2F\u2D68-\u2D6E\u2D71-\u2D7E\u2D97-\u2D9F\u2DA7\u2DAF\u2DB7\u2DBF\u2DC7\u2DCF\u2DD7\u2DDF\u2E3C-' + '\u2E7F\u2E9A\u2EF4-\u2EFF\u2FD6-\u2FEF\u2FFC-\u2FFF\u3040\u3097\u3098\u3100-\u3104\u312E-\u3130\u318F' + '\u31BB-\u31BF\u31E4-\u31EF\u321F\u32FF\u4DB6-\u4DBF\u9FCD-\u9FFF\uA48D-\uA48F\uA4C7-\uA4CF\uA62C-\uA63F' + '\uA698-\uA69E\uA6F8-\uA6FF\uA78F\uA794-\uA79F\uA7AB-\uA7F7\uA82C-\uA82F\uA83A-\uA83F\uA878-\uA87F\uA8C5-' + '\uA8CD\uA8DA-\uA8DF\uA8FC-\uA8FF\uA954-\uA95E\uA97D-\uA97F\uA9CE\uA9DA-\uA9DD\uA9E0-\uA9FF\uAA37-\uAA3F' + '\uAA4E\uAA4F\uAA5A\uAA5B\uAA7C-\uAA7F\uAAC3-\uAADA\uAAF7-\uAB00\uAB07\uAB08\uAB0F\uAB10\uAB17-\uAB1F\uAB27' + '\uAB2F-\uABBF\uABEE\uABEF\uABFA-\uABFF\uD7A4-\uD7AF\uD7C7-\uD7CA\uD7FC-\uF8FF\uFA6E\uFA6F\uFADA-\uFAFF' + '\uFB07-\uFB12\uFB18-\uFB1C\uFB37\uFB3D\uFB3F\uFB42\uFB45\uFBC2-\uFBD2\uFD40-\uFD4F\uFD90\uFD91\uFDC8-' + '\uFDEF\uFDFE\uFDFF\uFE1A-\uFE1F\uFE27-\uFE2F\uFE53\uFE67\uFE6C-\uFE6F\uFE75\uFEFD-\uFF00\uFFBF-\uFFC1' + '\uFFC8\uFFC9\uFFD0\uFFD1\uFFD8\uFFD9\uFFDD-\uFFDF\uFFE7\uFFEF-\uFFFB\uFFFE\uFFFF]']: '' } for (const character of Object.keys(fullWidthReplacements)) { filename = filename.replace(new RegExp(character, 'gi'), fullWidthReplacements[character]) } // Remove leading spaces, which are not accepted by GM.download(). return filename.trim() } /** * Downloads a file, which is limited to a torrent for this function, using the less reliable iframe method. * * This function assumes that a testing step has been completed before it to check for application errors. * * @param {HTMLDivElement} galleryDownloadButton - The gallery download button associated with this attempt. * @param {string} downloadUrl - The URL that will be loaded by the iframe to receive the file download. */ const downloadUsingIframe = function (galleryDownloadButton, downloadUrl) { const iframe = document.createElement('iframe') iframe.display = 'none' iframe.src = downloadUrl document.body.appendChild(iframe) // Remove the download iframe only after 15 seconds because torrent downloads might start slowly. setTimeout(() => { document.body.removeChild(iframe) }, 15000) changeGalleryDownloadState(galleryDownloadButton, 'done') } /** * Changes the state of a gallery download button and the concurrent download counter, and cleans up where needed. * * @param {HTMLDivElement} galleryDownloadButton - The gallery download button to be changed. * @param {string} targetState - 'idle', 'loading', 'downloading', 'done', 'unavailable', or 'failed'. * @param {string} [tooltip] - An optional non-empty tooltip to be set on the gallery download button. */ const changeGalleryDownloadState = function (galleryDownloadButton, targetState, tooltip) { galleryDownloadButton.className = `${galleryDownloadButton.className.match(/^(cs|cn)/)[1]} ` + `galleryDownloadButton ${targetState}` // Set the button text. if (targetState === 'idle') { galleryDownloadButton.textContent = 'Download' } else { galleryDownloadButton.textContent = targetState.charAt(0).toUpperCase() + targetState.substring(1) } // Set the button tooltip, or remove it when the target state is not one of the error states. if (targetState === 'unavailable' || targetState === 'failed') { if (typeof tooltip !== 'undefined') { // Set the title attribute directly instead of using setTooltip(), so that these tooltips will not be disabled // even if "settings.script.buttonTooltipEnabled" is false. This way the user can always see the reason why // each download failed. galleryDownloadButton.title = tooltip } } else { galleryDownloadButton.removeAttribute('title') } // Increase the concurrent download counter when a gallery download is started. if (targetState === 'loading') { concurrentDownloadsRunning += 1 } // Hide the gallery cover thumbnail after a successful download when this option is enabled and applicable. if (targetState === 'done') { if (typeof galleryDownloadButton.dataThumbnail !== 'undefined' && shortcuts.hideThumbnailEnabled) { galleryDownloadButton.dataThumbnail.parentNode.style.visibility = 'hidden' } } // Clean up when moving to any of the terminal states. if (targetState !== 'loading' && targetState !== 'downloading') { // Abort any running XHR, and remove the XHR object and the recorded archiver hostname. Doing the clean-up here // allows XHRs to abort themselves in their event properties without side effects. if (typeof galleryDownloadButton.xhr !== 'undefined') { galleryDownloadButton.xhr.abort() delete galleryDownloadButton.xhr } if (typeof galleryDownloadButton.archiver !== 'undefined') { delete galleryDownloadButton.archiver } concurrentDownloadsRunning -= 1 if (shortcuts.pageDownloadEnabled) { checkPageCompletion() // Attempt the next gallery download in the page download mode. This does guarantee a download can be started. if (inPageDownloadMode) { attemptPageDownload() } } } } // Error handling /** * Checks an error message and returns the type of the error. * * Some of the errors come from the list of technical issues: https://ehwiki.org/wiki/Technical_Issues * * @param {string} message - A message that should indicate the type of error. */ const checkErrorMessage = function (message) { message = message.toLowerCase() if (message.includes('service unavailable')) { // When the site returns this 503 error, the statusText seems empty. return 'serviceUnavailableError' } else if (message.includes('backend fetch failed')) { return 'backendFetchError' } else if (message.includes('the archiver assigned to this archive is temporarily unavailable')) { return 'unavailableArchiverError' } else if (message.includes('the torrent file could not be found')) { return 'unavailableTorrentError' } else if (message.includes('this gallery is currently unavailable')) { // This probably only happens when the torrent list of a removed gallery is accessed. This if branch should // never run, because the status code will be 404 in this case and the XHR will directly call handleError(). return 'unavailableGalleryError' } else if (message.includes('you have clocked too many downloaded bytes on this gallery')) { return 'downloadedBytesError' } else if (message.includes('expired or invalid session')) { return 'expiredSessionError' } else if (message.includes('you are opening pages too fast')) { // Not sure whether this warning actually shows up in practice, but it is included in case it will help. return 'heavyLoadError' } else if (message.includes('your ip address has been temporarily banned')) { return 'temporaryBanError' } else { return 'unknownError' } } /** * Handles an error by changing the state of the gallery download button involved and showing an alert. * * @param {HTMLDivElement} galleryDownloadButton - The gallery download button associated with the error. * @param {string} error - The type of the error. * @param {string} [html] - The outer HTML of the entire document element of the page that gave the error. */ const handleError = function (galleryDownloadButton, error, html) { let alertMessage = errors[error] switch (error) { // These are download errors that can always happen. Message alerts for these can be switched off. The download // will not be retried on a network error because the network problem may last for a while. case 'networkError': case 'serviceUnavailableError': case 'backendFetchError': case 'unavailableArchiverError': case 'unavailableTorrentError': changeGalleryDownloadState(galleryDownloadButton, 'unavailable', alertMessage) shortcuts.downloadAlertsEnabled && alert(alertMessage) break case 'unavailableGalleryError': case 'downloadedBytesError': case 'expiredSessionError': case 'illegalFilenameError': changeGalleryDownloadState(galleryDownloadButton, 'failed', alertMessage) shortcuts.downloadAlertsEnabled && alert(alertMessage) break // These errors relate to temporary bans and are always shown. case 'heavyLoadError': case 'temporaryBanError': changeGalleryDownloadState(galleryDownloadButton, 'unavailable', alertMessage) // Stop the page download mode if it is active, because otherwise most downloads would just fail. if (inPageDownloadMode) { document.getElementById('pageDownloadButton').click() alertMessage += ' The page download has been stopped.' } alert(alertMessage) break // These are setup errors that would only happen when this script is not used properly. Alerts are always shown // for these because the user should immediately change how this script is used. case 'notLoggedInError': case 'autoSelectHathError': case 'unqualifiedHathError': case 'gmDownloadFileExtensionError': case 'gmDownloadNotEnabledError': case 'gmDownloadNotSupportedError': case 'crossOriginNotAllowedError': changeGalleryDownloadState(galleryDownloadButton, 'failed', alertMessage) // Stop the page download mode if it is active, because otherwise most downloads would just fail. if (inPageDownloadMode) { document.getElementById('pageDownloadButton').click() alertMessage += ' The page download has been stopped.' } alert(alertMessage) break // Unknown errors are always shown but do not stop the page download mode. The notification popup will however // pause the start of new gallery downloads in this mode until it is clicked. case 'unknownError': changeGalleryDownloadState(galleryDownloadButton, 'failed', alertMessage) // Stop the page download mode if it is active, because new downloads can also fail. if (inPageDownloadMode) { document.getElementById('pageDownloadButton').click() alertMessage += ' The page download has been stopped.' } // A log file can be automatically generated and downloaded when an unknown error is encountered. if (typeof html !== 'undefined') { alertMessage += ' An error log will be automatically downloaded, which can be submitted to the author in ' + 'a bug report.' const errorLog = `Function:\nuseAutomatedDownloads\n\nURL:\n${windowUrl}\n\nHTML:\n${html}` downloadTextData(errorLog, `${api.info.script.name} v${api.info.script.version} - Error Log`) } alert(alertMessage) } } // Torrent download chain /** * Reads a torrent list page, checks the torrents and selects the best one to download if possible. * * It uses attemptDownloadStep() instead of testDownloadStatus() to test the download status, because torrents are * small enough to be fully loaded without a delay, and a type of error can occur with a status code of 200, which * can only be caught on load. * * @type {onloadFunction} * @param {HTMLDivElement} galleryDownloadButton - The gallery download button associated with this download chain. * @param {Document} documentReceived - The parsed document returned by the last XHR. */ const selectTargetTorrent = function (galleryDownloadButton, documentReceived) { // Check whether the page is actually a torrent list page. This check should be unnecessary, which is why an // unknown error will be thrown. if (!documentReceived.getElementById('torrentinfo')) { handleError(galleryDownloadButton, 'unknownError', documentReceived.documentElement.outerHTML) return } // Obtain the status of each torrent in the torrent list, but only include the ones that have active download // links i.e., exclude expunged but seeded torrents still staying in the list. let torrents = Array.from(documentReceived.getElementsByTagName('table'), analyseTorrentTable) .filter(torrent => typeof torrent !== 'undefined') if (shortcuts.torrentRequirementsEnabled && shortcuts.archiveDownloadEnabled) { // When the seed requirement is enabled and the archive download is also enabled as a fallback option, firstly // obtain the up-to-date, seeded torrents. torrents = torrents.filter(torrent => torrent.timestamp >= galleryDownloadButton.timestamp) .filter(torrent => torrent.seeds > 0) // Then, sort them by size in descending order. When two torrents have the same size, a multi-criteria sort is // done to prioritise the one with more seeds. torrents = torrents.sort((a, b) => b.size - a.size === 0 ? b.seeds - a.seeds : b.size - a.size) if (torrents.length > 0 && torrents[0].size > shortcuts.ignoreRequirementsSize) { // Download the largest torrent right away if the gallery is too large for archive download judging by this // largest torrent. attemptDownloadStep(galleryDownloadButton, torrents[0].torrent, downloadTorrent) } else { // Otherwise filter the torrents by the minimum seed number requirement and download the largest. torrents = torrents.filter(torrent => torrent.seeds >= shortcuts.minimumSeedNumber) if (torrents.length > 0) { attemptDownloadStep(galleryDownloadButton, torrents[0].torrent, downloadTorrent) } else { // Automatically download the archive instead when the available torrents do not meet the requirments. galleryDownloadButton.torrent = undefined // Directly use the code from handleGalleryDownload() instead of moving the button back to the "idle" state // and clicking it to download the archive, because that first step will trigger the download of another // gallery and cause this gallery to be temporarily skipped in the page download mode. if (!shortcuts.archiveDownloadType.includes('H@H') && typeof api.download === 'undefined') { handleError(galleryDownloadButton, 'gmDownloadNotSupportedError') } else { attemptDownloadStep(galleryDownloadButton, galleryDownloadButton.gallery, passGalleryView) } } } } else { // When the seed requirement and the archive download option are not both enabled, download the most seeded // torrent without any checks. torrents = torrents.sort((a, b) => b.seeds - a.seeds) attemptDownloadStep(galleryDownloadButton, torrents[0].torrent, downloadTorrent) } } /** * Helps selectTargetTorrent() to extract data from a torrent information table in a torrent list page. * * @param {HTMLTableElement} table - A table element that shows the status of a torrent. * @returns {Object} An object literal containing the torrent data, or undefined if the torrent has been expunged. */ const analyseTorrentTable = function (table) { const torrent = table.getElementsByTagName('a')[0] if (typeof torrent === 'undefined') { // If an anchor cannot be found within a torrent information table, it means this torrent has been expunged and // its download link is unavailable. It should be removed from the torrent list when it becomes unseeded. // undefined will be returned and this torrent will need to be screen out based on this. } else { // Convert GB and KB to MB. const sizeAndUnit = table.textContent.match(/Size:\s*([0-9.]+)\s*(KB|MB|GB)/) let size = +sizeAndUnit[1] if (sizeAndUnit[2] === 'KB') { size /= 1024 } else if (sizeAndUnit[2] === 'GB') { size *= 1024 } return { // torrent.href is always the URL for the reditributable torrent, but the onclick attribute will have the // modified URL for the personalised torrent when logged in; if not logged in, the onclick will just have the // same URL as .href. torrent: shortcuts.personalisedTorrentEnabled ? torrent.getAttribute('onclick').match(/^document.location='(.+)'; return false$/)[1] : torrent.href, timestamp: Date.parse(table.textContent.match(/Posted:\s*([0-9-]+\s*[0-9:]+)/)[1]), size, seeds: +table.textContent.match(/Seeds:\s*(\d+)/)[1], peers: +table.textContent.match(/Peers:\s*(\d+)/)[1] } } } /** * Checks for a possible error and downloads the torrent file using GM.download() or an iframe. * * @type {onloadFunction} * @param {HTMLDivElement} galleryDownloadButton - The gallery download button associated with this download chain. * @param {Document} documentReceived - The parsed document returned by the last XHR. * @param {Object} responseReceived - The response from the last XHR, which is required to start the download. */ const downloadTorrent = function (galleryDownloadButton, documentReceived, responseReceived) { // When this function runs, the reponse should have a status code of 200, and a document with the torrent data in // "document.body" from DOMparser should be received. There is one error that can happen before this, which is the // "unavailableTorrentError", and it is now checked for in the onload part of attemptDownloadStep(). This error // should only happen to redistributable torrents. const filename = decodeURIComponent(escape(responseReceived.responseHeaders.match(/filename="(.+)"$/m)[1])) if (shortcuts.apiTorrentDownloadEnabled) { downloadUsingApi(galleryDownloadButton, responseReceived.finalUrl, filename) } else { downloadUsingIframe(galleryDownloadButton, responseReceived.finalUrl) } } // Common step in H@H and archive download chains /** * Reads a gallery view page and proceeds to its archive selection page, or skips content warning first when needed. * * @type {onloadFunction} * @param {HTMLDivElement} galleryDownloadButton - The gallery download button associated with this download chain. * @param {Document} documentReceived - The parsed document returned by the last XHR. */ const passGalleryView = function (galleryDownloadButton, documentReceived) { // Check whether the document received is actualy a gallery view page or a content warning page, which would need // to be skipped first by attempting this step again using the "view gallery" URL. if (xpathSelector(documentReceived, './/a[text() = "Get Me Outta Here"]') !== null) { const skipWarningUrl = xpathSelector(documentReceived, './/a[text() = "View Gallery"]').href attemptDownloadStep(galleryDownloadButton, skipWarningUrl, passGalleryView) } else { const archiveDownloadButton = xpathSelector(documentReceived, './/a[text() = "Archive Download"]') if (!archiveDownloadButton) { // There is an error if the archive download link is not found. This should not happen, but is checked just in // case. handleError(galleryDownloadButton, 'unknownError', documentReceived.documentElement.outerHTML) return } const archiveSelectionUrl = archiveDownloadButton.getAttribute('onclick').match(/popUp\('(.+?)',/)[1] if (shortcuts.archiveDownloadType.includes('H@H')) { attemptDownloadStep(galleryDownloadButton, archiveSelectionUrl, selectTargetHath) } else { attemptDownloadStep(galleryDownloadButton, archiveSelectionUrl, selectTargetArchive) } } } // H@H download chain /** * Reads an archive selection page and sends form data to schedule a H@H download. * * @type {onloadFunction} * @param {HTMLDivElement} galleryDownloadButton - The gallery download button associated with this download chain. * @param {Document} documentReceived - The parsed document returned by the last XHR. */ const selectTargetHath = function (galleryDownloadButton, documentReceived) { // Check whether the document received is actualy the archive selection page. #hathdl_form is used to check this. if (!documentReceived.getElementById('hathdl_form')) { if (documentReceived.querySelector('form[name = "ipb_login_form"]')) { // A page with a login form will be served instead when the user is not logged in. handleError(galleryDownloadButton, 'notLoggedInError') } else { // The archive selection page was not served because the archiver is on "auto select" in the user's EH gallery // settings. This chain will have to be stopped because the user needs to use "manual select" instead. handleError(galleryDownloadButton, 'autoSelectHathError') } return } const hathDownloadType = shortcuts.archiveDownloadType.match(/H@H (\d+x|original)/)[1] let hathFormData // If a resample version is selected, test whether it is available before setting the form data. if (hathDownloadType !== 'original' && xpathSelector(documentReceived, `.//a[text() = "${hathDownloadType}"]`) !== null) { hathFormData = `hathdl_xres=${hathDownloadType.slice(0, -1)}` } else { // Download the original version if it is selected or if the target resample version is not available. hathFormData = 'hathdl_xres=org' } const hathFormUrl = documentReceived.getElementById('hathdl_form').action attemptDownloadStep(galleryDownloadButton, hathFormUrl, confirmHathInstruction, hathFormData) } /** * Reads a H@H download confirmation page to determine whether the instruction was successful. * * It cannot detect downloads that failed for reasons other than non-qualification, because the author cannot * trigger these errors. * * @type {onloadFunction} * @param {HTMLDivElement} galleryDownloadButton - The gallery download button associated with this download chain. * @param {Document} documentReceived - The parsed document returned by the last XHR. */ const confirmHathInstruction = function (galleryDownloadButton, documentReceived) { const bodyText = documentReceived.body.textContent if (bodyText.includes('Downloads should start processing within a couple of minutes')) { changeGalleryDownloadState(galleryDownloadButton, 'done') } else if (bodyText.includes('You must have a H@H client assigned to your account to use this feature')) { handleError(galleryDownloadButton, 'unqualifiedHathError') } else { handleError(galleryDownloadButton, 'unknownError', documentReceived.documentElement.outerHTML) } } // Archive download chain /** * Reads an archive selection page and sends form data to ask for a doggie bag archive download. * * @type {onloadFunction} * @param {HTMLDivElement} galleryDownloadButton - The gallery download button associated with this download chain. * @param {Document} documentReceived - The parsed document returned by the last XHR. */ const selectTargetArchive = function (galleryDownloadButton, documentReceived) { // Check whether the document received is actualy the archive selection page. #hathdl_form is used to check this. if (!documentReceived.getElementById('hathdl_form')) { if (documentReceived.querySelector('form[name = "ipb_login_form"]')) { // A page with a login form will be served instead when the user is not logged in. handleError(galleryDownloadButton, 'notLoggedInError') } else { // The archive selection page was not served because the archiver is on "auto select" in the user's EH gallery // settings. In this case, the arguments can be directly passed to the function for the next step. acquireArchiverAddress(galleryDownloadButton, documentReceived) } return } let archiveTypeButton let archiveFormData if (shortcuts.archiveDownloadType === 'resample archive') { archiveTypeButton = documentReceived.querySelector('input[value = "Download Resample Archive"]:not([disabled])') if (!archiveTypeButton) { // Download the original archive if the resample archive is not available for the gallery. archiveTypeButton = documentReceived.querySelector('input[value = "Download Original Archive"]') archiveFormData = 'dltype=org&dlcheck=Download Original Archive' } else { archiveFormData = 'dltype=res&dlcheck=Download Resample Archive' } } else { archiveTypeButton = documentReceived.querySelector('input[value = "Download Original Archive"]') archiveFormData = 'dltype=org&dlcheck=Download Original Archive' } const archiveFormUrl = archiveTypeButton.closest('form').action attemptDownloadStep(galleryDownloadButton, archiveFormUrl, acquireArchiverAddress, archiveFormData) } /** * Reads a locating server or file processing page to acquire the archiver URL. * * @type {onloadFunction} * @param {HTMLDivElement} galleryDownloadButton - The gallery download button associated with this download chain. * @param {Document} documentReceived - The parsed document returned by the last XHR. */ const acquireArchiverAddress = function (galleryDownloadButton, documentReceived) { // The archiver URL is always in the script element. const redirectScript = xpathSelector(documentReceived, './/script[contains(text(), "function gotonext()")]') if (!redirectScript) { handleError(galleryDownloadButton, 'unknownError', documentReceived.documentElement.outerHTML) return } let archiverUrl = redirectScript.textContent.match(/document\.location = "(.+?)"/)[1] const delay = +redirectScript.textContent.match(/setTimeout\("gotonext\(\)", (\d+)\)/)[1] try { // Record the archiver hostname for steps after this, which will involve relative URLs. galleryDownloadButton.archiver = `http://${(new URL(archiverUrl)).hostname}` } catch (error) { // If the URL cannot be parsed, it must be the relative URL from a file processing page. Since file processing // happens after locating server, the archiver hostname has been recorded and the relative URL can be converted // to an absolute one. archiverUrl = galleryDownloadButton.archiver + archiverUrl } // Wait out the delay, which would be significant on a file processing page, before starting the next step, // because otherwise it may not be successful. setTimeout(function () { attemptDownloadStep(galleryDownloadButton, archiverUrl.replace('?autostart=1', ''), acquireArchiveAddress) }, delay) } /** * Reads a download ready page to acquire the download URL for the archive file. * * @type {onloadFunction} * @param {HTMLDivElement} galleryDownloadButton - The gallery download button associated with this download chain. * @param {Document} documentReceived - The parsed document returned by the last XHR. */ const acquireArchiveAddress = function (galleryDownloadButton, documentReceived) { const downloadLink = xpathSelector(documentReceived, './/a[text() = "Click Here To Start Downloading"]') // Check whether the document received is actualy a download ready page, because a file processing page could be // served instead. The file processing step probably only happens when the archive takes time to recreate, and // it is just an additional step between locating server and download ready. if (!downloadLink) { const redirectScript = xpathSelector(documentReceived, './/script[contains(text(), "function gotonext()")]') if (redirectScript !== null) { // In this case, the function for the last step, acquireArchiverAddress(), is designed to handle this file // processing page. acquireArchiverAddress(galleryDownloadButton, documentReceived) } else { handleError(galleryDownloadButton, 'unknownError', documentReceived.documentElement.outerHTML) } return } const downloadUrl = `${galleryDownloadButton.archiver}${(new URL(downloadLink.href)).pathname}?start=1` testDownloadHeaders(galleryDownloadButton, downloadUrl, downloadArchive) } /** * Downloads the archive file using GM.download(). * * @type {onloadFunction} * @param {HTMLDivElement} galleryDownloadButton - The gallery download button associated with this download chain. * @param {undefined} documentReceived - The parsed document returned by the last XHR, which is not needed here. * @param {Object} responseReceived - The response from the last XHR, which is required to start the download. */ const downloadArchive = function (galleryDownloadButton, documentReceived, responseReceived) { const filename = decodeURIComponent(escape(responseReceived.responseHeaders.match(/filename="(.+)"$/m)[1])) downloadUsingApi(galleryDownloadButton, responseReceived.finalUrl, filename) } // Page download /** * Changes the text and tooltip on the page download button. * * @param {string} targetState - 'idle', 'downloading', 'done', or 'failed'. * @param {number} [errorCount] - An optional integer for the number of galleries that ended up in terminal error * states. It is only required if the "targetState" is 'failed'. */ const changePageDownloadState = function (targetState, errorCount) { const pageDownloadButton = document.getElementById('pageDownloadButton') // Set button text, button tooltip and page title in various states. switch (targetState) { case 'idle': if (shortcuts.pageRangeDownloadEnabled) { pageDownloadButton.value = 'Download Page(s)' setTooltip(pageDownloadButton, 'Download all galleries from this page onwards until the last page by ' + 'automatically starting gallery downloads, unless errors occur. The number of concurrent downloads is ' + `limited to ${shortcuts.pageDownloadNumber} per tab`) } else { pageDownloadButton.value = 'Download This Page' setTooltip(pageDownloadButton, 'Download all galleries on this page by automatically starting gallery ' + `downloads. The number of concurrent downloads is limited to ${shortcuts.pageDownloadNumber} per tab`) } if (inPageDownloadMode) { document.title = '❙❙ ' + originalTitle } else { document.title = originalTitle } break case 'downloading': pageDownloadButton.value = 'Stop Page Download' setTooltip(pageDownloadButton, 'The master script is currently automatically downloading all galleries on ' + 'this page. Press this button to stop this process, but running downloads are still allowed to finish.') document.title = '⤓ ' + originalTitle break case 'done': pageDownloadButton.value = 'Page Downloaded' setTooltip(pageDownloadButton, 'All galleries on this page have been downloaded') document.title = '✓ ' + originalTitle break case 'failed': pageDownloadButton.value = `${errorCount} Unavailable/Failed` setTooltip(pageDownloadButton, 'The master script has tried to download all galleries on this page, ' + 'but at least some of them failed and hence require your attention') document.title = '✗ ' + originalTitle } if (targetState === 'downloading') { inPageDownloadMode = true if (shortcuts.downloadProtectionEnabled) { startDownloadProtection() } } else if (inPageDownloadMode) { // When the button state is set to a terminal state, the page download mode is not necessarily active. inPageDownloadMode = false if (shortcuts.downloadProtectionEnabled) { endDownloadProtection() } if (targetState === 'failed') { alert('The master script has tried to download all galleries on this page, but at least some of them ' + 'failed and hence require your attention.') } else if (targetState === 'done' && shortcuts.pageRangeDownloadEnabled) { schedulePageDownload() } } } /** * Activates gallery download buttons in the "idle" state to automatically start gallery downloads. * * This function can be called from a gallery download button as an event handler, and also from * changeButtonState(). * * Buttons in "unavailable" and "failed" states will not be retried by this function, because these states can * persist for a while. However, it is easy to add an option in the future to bring back the ability to * automatically retry buttons in the "unavailable" state. * * @type {clickEventHandler} * @param {MouseEvent} clickEvent - The event object passed to this event handler on click. */ const attemptPageDownload = function (clickEvent) { // When the page download button is clicked, do nothing if the page has been completed. This makes it possible to // update this button after page completion is declared. if (typeof clickEvent !== 'undefined' && checkPageCompletion()) { return } // When the page download button is clicked, do nothing if the control panel is currently open. if (typeof clickEvent !== 'undefined' && document.getElementById('controlPanel')) { return } if (typeof clickEvent === 'undefined') { // If this function is called without the "clickEvent" argument, it must have been called automatically from // changeButtonState() after a download has reached a terminal state in the page download mode. In this case, // start one gallery download to fill in the gap if possible. Since it dynamically looks for the next one to // download, the user can manually skip a gallery during a running page download by editing the HTML element and // adding "done" to its class. const nextIdleButton = document.querySelector('.galleryDownloadButton.idle') // When there are download attempts still running but no button left in the "idle" state, checkPageCompletion() // will not declare completion and hence this function will still run. Therefore, it needs to check whether // there is still a "idle" button to be clicked. Also, it should not start a download when the concurrent // download slots are full, which could happen if the user has manually started some downloads on the same page. if (nextIdleButton !== null && concurrentDownloadsRunning < shortcuts.pageDownloadNumber) { nextIdleButton.click() } } else { // If this function is called from the button, start or stop the page download mode. if (!inPageDownloadMode) { // If the page download mode is not already active when this button is clicked, activate this mode and start // the set number of concurrent downloads if possible. changePageDownloadState('downloading') const idleButtons = document.querySelectorAll('.galleryDownloadButton.idle') // Fewer or no downloads will be started if there are already some downloads running on this page when this // mode is entered. const numberToStart = Math.min(shortcuts.pageDownloadNumber - concurrentDownloadsRunning, idleButtons.length) for (let i = 0; i < numberToStart; ++i) { idleButtons[i].click() } } else { // If the page download mode is already active when this button is clicked, deactivate this mode. Running // downloads will still finish, but further downloads will not be started. changePageDownloadState('idle') } } } /** * Checks whether all galleries on the current gallery list page have been attempted. * * This function is only applicable when the page download option has been enabled. It will be called every time a * gallery download reaches a terminal state, and when the page download button is clicked. Since this function only * checks the number of gallery download buttons, page completion will also be safely declared on a gallery list * page which has been emptied by filters. * * @returns {boolean} true if the current page has been completed; otherwise false. */ const checkPageCompletion = function () { if (document.querySelectorAll('.galleryDownloadButton.idle').length > 0) { return false } // Only declare page completion when there is also no button in the "loading" or "downloading" state, because // these buttons may not necessarily succeed. const runningCount = document.querySelectorAll('.galleryDownloadButton.loading, ' + '.galleryDownloadButton.downloading').length if (runningCount > 0) { return false } // Start declaring page completion after checking for the number of buttons in the states above. // Check whether there are failed downloads that require the user's attention. const errorCount = document.querySelectorAll('.galleryDownloadButton.unavailable, ' + '.galleryDownloadButton.failed').length if (errorCount === 0) { changePageDownloadState('done') } else { changePageDownloadState('failed', errorCount) } return true } /** * Records the next page to download and goes to this page to start the page download where possible. */ const schedulePageDownload = async function () { // Check whether there is a next page by trying to select a clickable next page button. This button is always // there, but it only has the "onclick" attribute and an anchor child element when there is a next page. const nextPage = document.querySelector('#unext[href]') if (!nextPage) { return } // Record the URL of the next page to be downloaded using the URL of the current page as key. This should have no // negative effect if this key already exists. Then go to the next page after updating the userscript storage. values.useAutomatedDownloads.pagesToDownload[windowUrl] = nextPage.href await api.setValue('values', JSON.stringify(values)) nextPage.click() } /** * Starts the page download if the current page was marked for it, and updates the userscript storage. */ const continuePageDownload = async function () { const pagesToDownload = values.useAutomatedDownloads.pagesToDownload if (!shortcuts.pageDownloadEnabled || !shortcuts.pageRangeDownloadEnabled || Object.keys(pagesToDownload).length === 0) { return } for (const key of Object.keys(pagesToDownload)) { if (windowUrl === pagesToDownload[key]) { delete pagesToDownload[key] // When the current page is empty due to filters but there is a next page available, clicking the page // download button below will directly start schedulePageDownload(). Therefore, it is safer to wait for the // storage update to finish before clicking the button, because otherwise schedulePageDownload() and this // function may save different versions of the same object property. await api.setValue('values', JSON.stringify(values)) document.getElementById('pageDownloadButton').click() } } } /** * Disables all pointer events on buttons and links in gallery lists and makes galleries open in new tab. */ const startDownloadProtection = function () { const downloadProtectionStyles = ` /* search box */ .idi input, /* script buttons */ #nb, #openConfigButton, #additionalFiltersButton, /* search navigation area */ .searchtext a, .searchnav a, .searchnav select, /* bottom links */ .dp > a, /* watched list only */ .ip a, /* favourite list only */ .ido > .nosel, .ido > .nosel + div, #favact, /* uploader link in four display modes */ td.glhide > div > a, .gl3e > .ir + div > a { pointer-events: none; } ` appendStyleText(document.head, 'downloadProtectionStyles', downloadProtectionStyles) if (!settings.openGalleriesSeparately.featureEnabled) { openGalleriesSeparately() } } /** * Reverses the effects of startDownloadProtection(). */ const endDownloadProtection = function () { document.head.removeChild(document.getElementById('downloadProtectionStyles')) // Reverse the effects of openGalleriesSeparately() when it is not enabled in the settings. if (!settings.openGalleriesSeparately.featureEnabled) { const galleryLinks = document.querySelectorAll('.gl3m.glname > a, .gl3c.glname > a, .gl1e > div > a, ' + '.gl2e > div > a, .gl1t > a, .gl4t.glname > div > a, .gl3t > a') for (const galleryLink of galleryLinks) { galleryLink.onclick = null } } } addShortcutStyles() addDownloadButtons() continuePageDownload() } /** * Opens galleries in new tab by default from all types of gallery lists. */ const openGalleriesSeparately = function () { if (pageType !== 'gallery list') { return } let galleryLinks switch (displayMode) { case 'minimal': case 'minimal+': galleryLinks = document.querySelectorAll('.gl3m.glname > a') break case 'compact': galleryLinks = document.querySelectorAll('.gl3c.glname > a') break case 'extended': galleryLinks = document.querySelectorAll('.gl1e > div > a, .gl2e > div > a') break case 'thumbnail': // In this display mode, the anchors on gallery titles are located differently in the DOM tree between the // search index (.gl1t > a) and the favorite list (.gl1t > .gl4t.glname.glft > div > a), but the anchors on // gallery thumbnails are not. Therefore, three selectors are needed in total to select all links. galleryLinks = document.querySelectorAll('.gl1t > a, .gl4t.glname > div > a, .gl3t > a') } for (const galleryLink of galleryLinks) { galleryLink.onclick = function (anchorEvent) { anchorEvent.preventDefault() window.open(galleryLink.href) } if (settings.openGalleriesSeparately.directMpvEnabled) { galleryLink.href = galleryLink.href.replace(/\/g\//, '/mpv/') } } } /** * Adds buttons to the bottom right corner which will automatically scroll the page to the very top or bottom. * * It might be more convenient to put these buttons in other places as well, but at the moment they are only added to * the bottom right corner because this is where websites usually place this kind of button. */ const addJumpButtons = function () { // This feature does not run in the MPV, because it can cause accidents and also block the thumbnails when // relocateMpvThumbnails() is used. These buttons also do not seem necessary in HV, which have short pages. if (pageType === 'MPV view' || pageType === 'HentaiVerse') { return } let jumpButtonStyles // "font-size" in the styles below needs "!important" on the gallery management page. In general, the buttons // automatically blend in quite well. if (settings.addJumpButtons.jumpButtonStyle === 'fade-in circular buttons') { jumpButtonStyles = ` #jumpButtonHost { height: 23vh; width: 12vh; position: fixed; right: 2vh; bottom: 2vh; z-index: 3; opacity: 0; transition-duration: 0.3s; } #jumpButtonHost:hover { opacity: 1; } #jumpToTopButton, #jumpToBottomButton { height: 10vh; width: 10vh; border-radius: 5vh; margin: 0.5vh; box-shadow: 0 0 1vh 0 rgba(0, 0, 0, 0.6); font-size: 6vh !important; line-height: 6vh; }` } else if (settings.addJumpButtons.jumpButtonStyle === 'slide-in rectangular buttons') { jumpButtonStyles = ` #jumpButtonHost { height: 20vh; width: 10vw; position: fixed; right: -8.5vw; bottom: 2vh; z-index: 3; box-shadow: 0 0 1vh 0 rgba(0, 0, 0, 0.6); transition: 0.3s; } #jumpButtonHost:hover { right: -3px; transition: 0.3s; } #jumpToTopButton, #jumpToBottomButton { height: 10vh; width: 10vw; margin: auto; font-size: 6vh !important; line-height: 6vh; } #jumpToTopButton { border-radius: 3px 0 0 0; border-bottom: 0; } #jumpToBottomButton { border-radius: 0 0 0 3px; border-top: 0; }` } const jumpBehaviour = settings.addJumpButtons.jumpBehaviourStyle === 'smoothly' ? 'smooth' : 'auto' const jumpToTop = function () { window.scrollTo({ top: 0, behavior: jumpBehaviour }) } const jumpToBottom = function () { const scrollHeight = (document.scrollingElement.scrollHeight || document.documentElement.scrollHeight || document.body.scrollHeight) window.scrollTo({ top: scrollHeight, behavior: jumpBehaviour }) } appendStyleText(document.head, 'jumpButtonStyles', jumpButtonStyles) const jumpButtonHost = document.createElement('div') jumpButtonHost.id = 'jumpButtonHost' jumpButtonHost.appendChild(createDmsButton('jumpToTopButton', '⭱', jumpToTop)) jumpButtonHost.appendChild(createDmsButton('jumpToBottomButton', '⭳', jumpToBottom)) document.body.appendChild(jumpButtonHost) } /** * Transform URLs to external websites in gallery comments to clickable links. * * This includes all URLs which do not appear as links by default. EHWiki, HV and two other EH domains are also * considered external by the site due to their domain names, and hence benefit from this feature. However, using this * feature is potentionally risky for users who cannot identify malicious links. * * This feature only transforms URLs that include the protocol part e.g., "https://", because otherwise it is too * difficult to detect valid URLs. */ const parseExternalLinks = function () { if (pageType !== 'gallery view') { return } /** * Replaces the first plain text occurrence of a URL with an anchor that shows and goes to this URL. * * @param {string} text - The text body in which at least one instance of the target URL exists. * @param {string} url - The target URL to be found and converted. */ const replaceUrlOnce = function (text, url) { let searchedSubstring = '' let searchSubstring = text while (true) { const offset = searchSubstring.indexOf(url) // Break the infinite loop when the target URL cannot be found. This probably only happens when the URL should // not be parsed due to the conditions below. if (offset === -1) { return text } // Test what is just before this instance of the target URL to check whether it is already inside an anchor tag. if (!/="|">/.test(searchSubstring.substring(offset - 2, offset))) { // Emphasise the domain like on the forums, unless the domain also belongs to EH. const domainEmphasis = `[<strong>${(new URL(url)).hostname}</strong>]` if (/(?:repo.|upload.)e-hentai\.org|ehwiki\.org|hentaiverse\.org/.test(domainEmphasis)) { return searchedSubstring + searchSubstring.replace(url, `<a href="${url}">${url}</a>`) } else { return searchedSubstring + searchSubstring.replace(url, `${domainEmphasis} <a href="${url}">${url}</a>`) } } searchedSubstring = searchedSubstring + searchSubstring.substring(0, searchSubstring.indexOf(url) + 1) searchSubstring = searchSubstring.substring(searchSubstring.indexOf(url) + 1) } } const comments = document.getElementsByClassName('c6') for (const comment of comments) { // Replace the "<br>" HTML tag to make the regex below easier. It is difficult to parse HTML with regex anyway. const formattedHTML = comment.innerHTML.split('<br>').join('\n') // The regex used to find URLs below is very simple but good enough so far. It tries to replicate how the site // will delimit URLs: In addition to spaces and line breaks, the symbols "[" / "]" / "," / "." / ";" / ":" // followed by a space or line break also delimit a URL; other symbols from the US international keyboard layout // would be treated as part of the URL by the site, such as other types of brackets. "">" and "</" are used to // delimit URLs in tags or surrounded by tags, respectively. const urls = formattedHTML.match(/https?:\/\/\S+?(?=[[\],.;:]?(?:\s|$)|">|<\/)/gm) if (!urls) { continue } // Sort the URLs found from long to short so that the longer URLs will be replaced first, because otherwise a URL // that includes another shorter URL will not be processed correctly. const orderedUrls = urls.sort((a, b) => b.length - a.length) for (const url of orderedUrls) { // Firefox still does not seem to support regex lookbehind. if (/(?:repo|upload)\.e-hentai\.org/.test(url) || !/(?:e-hentai|exhentai|ehgt)\.org/.test(url)) { comment.innerHTML = replaceUrlOnce(comment.innerHTML, url) } } } } /** * Removes the filename tooltips on the main images in the MPV. * * The tooltips on the thumbnail images are kept because they show page numbers and are hence useful for navigation. */ const removeMpvTooltips = function () { if (pageType !== 'MPV view') { return } /** * Observes subtree child list changes under the main image pane and removes tooltips when image anchors are loaded. * * This NodeList will be loaded in each mutation: [a, text, div.mi1] * * @param {} mutations */ const removeImageTooltips = function (mutations) { for (const mutation of mutations) { // Do nothing when images get dynamically removed. if (mutation.addedNodes.length === 0) { continue } for (const addedNode of mutation.addedNodes) { // Find the image anchor and remove the title attribute from div.mi0 > a > img[id ^= "imgsrc_"]. if (addedNode.nodeName === 'A') { const mainImage = addedNode.querySelector('img[id ^= "imgsrc_"]') if (mainImage !== null) { mainImage.removeAttribute('title') } } } } } const mpvObserver = new MutationObserver(removeImageTooltips) mpvObserver.observe(document.getElementById('pane_images_inner'), { childList: true, subtree: true }) } /** * Extends the availability of the daily dawn reward event so that it can be collected from any EH-related page. * * This makes the event available on the entire gallery system, the forums and HV. It is otherwise only available in * EH gallery view and on the news page. The EH wiki is the only place that is not supported, but that is only because * the script is not enabled on the EH wiki in general. To minimise intrusion, this feature does not give a * notification when a daily reward is collected. */ const collectDawnReward = function () { // These two types of pages would trigger the dawn event, so this function does not need to run. if (pageType === 'gallery view' && windowUrl.includes('e-hentai.org')) { return } else if (windowUrl === 'https://e-hentai.org/news.php') { return } // Check the time elapsed since the time the last reward that was collected by this feature became available, and // only start a collection when the time elapsed is greater than one day. "lastCollectedReward" below defaults to // zero, so this function will start a collection when it has never done it before. const currentDateTime = new Date() if (currentDateTime - values.collectDawnReward.lastCollectedReward <= 86400000) { return } // The XHR will not retry on network or HTML error, because the error may persist for a while and the user cannot // cancel this endless retry process. It is not a problem since it can be triggered in other tabs afterwards. const xhrDetails = { method: 'GET', synchronous: false, timeout: 60000, url: 'https://e-hentai.org/news.php', onload: function (response) { if (response.status === 200) { // Record this collection by the time this reward became available i.e., the date at 00:00 GMT. const currentRewardSlot = Date.UTC(currentDateTime.getUTCFullYear(), currentDateTime.getUTCMonth(), currentDateTime.getUTCDate()) values.collectDawnReward.lastCollectedReward = currentRewardSlot api.setValue('values', JSON.stringify(values)) } }, ontimeout: function (response) { collectDawnReward() } } api.xmlHttpRequest(xhrDetails) } // Helper functions -------------------------------------------------------------------------------------------------- /** * Produces a "text/css" style element and appends it to a host element as a child node. * * @param {HTMLElement} host - The element under which the style element will be added as a child node. * @param {string} [id] - An optional id that can be assigned to the style element. * @param {string} text - The text content of the style element, which should be the CSS styles. * @returns {HTMLStyleElement} The newly created style element. */ const appendStyleText = function (host, id, text) { const styleText = document.createElement('style') styleText.type = 'text/css' if (typeof id !== 'undefined') { styleText.id = id } styleText.textContent = text host.appendChild(styleText) return styleText } /** * Creates a button that uses the same visual style as the display mode selector in gallery lists. * * @param {string} [id] - An optional id that can be assigned to the button input element. * @param {string} text - The text content to be shown on the face of the button. * @param {clickEventHandler} onclick - The function that will run when this button is clicked. * @param {string} [tooltip] - An optional tooltip to be set on this button. * @returns {HTMLInputElement} The newly created button input element. */ const createDmsButton = function (id, text, onclick, tooltip) { const dmsButton = document.createElement('input') dmsButton.type = 'button' if (typeof id !== 'undefined') { dmsButton.id = id } dmsButton.className = 'dmsStyleButtons' dmsButton.value = text dmsButton.addEventListener('click', onclick) dmsButton.addEventListener('click', () => { dmsButton.blur() }) if (typeof tooltip !== 'undefined') { setTooltip(dmsButton, tooltip) } return dmsButton } /** * Sets a tooltip on an element. * * If this element is a button input element, then whether a tooltip will actually be added depends on a setting. * * @param {HTMLElement} element - The element on which the tooltip will be set. * @param {string} tooltip - The tooltip to be set. An empty string will remove the tooltip. */ const setTooltip = function (element, tooltip) { if (tooltip === '') { element.removeAttribute('title') } else { if (element.type === 'button') { if (settings.script.buttonTooltipEnabled) { element.title = tooltip } } else { element.title = tooltip } } } /** * Adds a link button to a navigation bar. * * @param {HTMLDivElement} bar - The navigation bar, which should be a division element. * @param {string} text - The visible text content of the link. * @param {string} link - The destination URL of the link. */ const addNavigationButton = function (bar, text, link) { const button = document.createElement('a') button.text = text button.href = link if (link === 'https://hentaiverse.org/') { button.setAttribute('onclick', 'popUp("https://hentaiverse.org/", 1250, 720); return false') } const buttonHost = document.createElement('div') buttonHost.appendChild(button) bar.appendChild(buttonHost) } /** * Selects one element in a document by evaluating an XPath expression. * * @param {Document} contextDocument - The document whose child nodes will be searched to select the target element. * @param {string} xpath - The XPath selector expression. * @returns {(HTMLElement|null)} The target element if it is successfully found; otherwise null. */ const xpathSelector = function (contextDocument, xpath) { return contextDocument.evaluate(xpath, contextDocument, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue } /** * Runs a function once after the "DOMContentLoaded" event fires. * * @param {Function} functionToRun - The function to be run, which should not require any parameter. */ const scheduleForInteractive = function (functionToRun) { if (document.readyState === 'loading') { window.addEventListener('DOMContentLoaded', functionToRun, { once: true }) } else { functionToRun() } } /** * Downloads data as a plain text file. * * @param {BlobPart} data - The data, which should usually be a string, to be included in the text file. * @param {string} filename - The filename with or without the ".txt" extension of the text file to be downloaded. */ const downloadTextData = function (data, filename) { const textFile = new Blob([data], { type: 'text/plain;charset=utf-8' }) const downloadLink = document.createElement('a') downloadLink.href = URL.createObjectURL(textFile) downloadLink.download = /^.*?\.txt$/.test(filename) ? filename : `${filename}.txt` downloadLink.style.display = 'none' document.body.appendChild(downloadLink) downloadLink.click() document.body.removeChild(downloadLink) setTimeout(() => { URL.revokeObjectURL(downloadLink.href) }, 1000) } })()