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)
}
})()