Partially fade/remove non-english, HQ thumbnails, mark as read, subs, version grouping etc.
// ==UserScript==
// @name NHentai Improved
// @namespace Hentiedup
// @version 3.0.0
// @description Partially fade/remove non-english, HQ thumbnails, mark as read, subs, version grouping etc.
// @author Hentiedup
// @license unlicense
// @match https://nhentai.net/*
// @require https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant GM_openInTab
// @icon https://i.imgur.com/1lihxY2.png
// @noframes
// ==/UserScript==
//TODO / Known Issues / Missing features
/*======================================
- Alt version grouping
- Infinite load:
- Handle other pages besides index and tag pages (so favorites and... any others?)
- Better error handling
- Handling now by not trying again until next refresh (to avoid spamming API calls when for example we've hit a call limit).
- Should diferentiate between error types andh behave accordingly + indicate to user
- Handle rare language flags
- Indicate to user that the load is happening
- Could probably make some of the logic a little smarter to avoid so many unncessary calls to the API during all the random soft refreshes (Maybe just delay the first load?)
- Tag page sort: If you change the sort method without a hard refresh, it apppears the API returns the results from the previous sort method (even though we send the API the right params)
- Temp solution: force hard reload after sort method change? (IMPLEMENTED)
- Sometimes get stuck not loading after a bunch of random-ish navigation?
- Reader: Improved Zoom doesn't play nice with "Image Scaling: Fit on Screen"
- indicate to user on Subscribe button press that something is happening (API call)
- cleanup code
- Subscriptions page:
- Add header to Subscriptions page?
- indicate to user that we are loading the counts (API call)
- Using a workaround to avoid problems caused by soft navigation by forcing a hard reload in places:
- Subscriptions page -> click on tag -> navigation to tag page
- tag page -> click on different sort method -> navigation to same tag page with different sort method
- Looks like NHentai is serving mixed content? (http / https). This can cause "Blocked loading mixed active content" errors when making API calls.
- This can be "fixed" by making the browser force https everywhere (which is good practice anyway)
- Chrome doesn't appear to have an equivelant option (it does have "Always use secure connections", but that's not good enough) and the various force HTTPS extensions don't work either.
*/
(() => {
'use strict';
//Non-english settings
const remove_non_english = GM_getValue("remove_non_english", false);
const partially_fade_all_non_english = GM_getValue("partially_fade_all_non_english", true);
const non_english_fade_opacity = GM_getValue("non_english_fade_opacity", 0.3);
//Comic reader system
const comic_reader_improved_zoom = GM_getValue("comic_reader_improved_zoom", true);
const remember_zoom_level = GM_getValue("remember_zoom_level", true);
let zoom_level; //set later, as needed
//Thumbnails
const browse_thumbnail_width = GM_getValue("browse_thumbnail_width", 0);
const browse_thumnail_container_width = GM_getValue("browse_thumnail_container_width", 0);
//Blacklist
const remove_native_blacklisted = GM_getValue("remove_native_blacklisted", true);
//Mark as read system
const mark_as_read_system_enabled = GM_getValue("mark_as_read_system_enabled", true);
const marked_as_read_fade_opacity = GM_getValue("marked_as_read_fade_opacity", 0.3);
const read_tag_font_size = GM_getValue("read_tag_font_size", 15);
let MARSet = new Set(); //set later, as needed
//Subscription system
const subscription_system_enabled = GM_getValue("subscription_system_enabled", true);
let SubsSlugIdMap = new Map(); //(key: slug, value: id) - set later, as needed
//Other
const block_extra_ads = GM_getValue("block_extra_ads", true);
const comment_improvements = true;
const infinite_load = GM_getValue("infinite_load", true);
const english_titles_for_infinite_load = !UserLangIsJapanese();
//Other global variables
const PageTypes = {
Other: "Other",
Reader: "Reader",
ComicInfo: "ComicInfo",
Subscriptions: "Subscriptions",
TagPage: "TagPage",
OwnUserPage: "OwnUserPage",
SettingsPage: "SettingsPage",
IndexPage: "IndexPage"
}
let currentPageType;
let currentPagePath;
let infiniteLoadInProgress = false;
const _waitForElementObservers = {};
const _waitForSwapObservers = {};
const _continuousObservers = {};
AddStylesheetsAsNeeded();
//Initial hard page load
//wait for app
waitForElement('#app', ReRunningLogic);
// Start listening to nav (soft page load)
onPageChange(({ url, type }) => {
infoWithTimestamp(`Navigated to: ${url} via ${type}`);
waitForElement('#app', ReRunningLogic);
});
//part of the script that needs to be re-run multiple times per hard load (due to dynamic page loading)
function ReRunningLogic() {
infoWithTimestamp("ReRunningLogic");
//observe for app innard swap. using nav as a cleaner way to detect swap while avoiding all the inner swaps content.
//TODO: might want to look at a better sort of anchor point to detect this
waitForSwap("nav", () => {
infoWithTimestamp("app content swap");
ReRunningLogic();
}, 500);
//Handle systems based on page
switch (SetCurrentPageType()) {
case PageTypes.Reader:
HandleHeaderModifications();
HandleComicReaderAsNeeded();
break;
case PageTypes.ComicInfo:
HandleHeaderModifications();
HandleComicInfoPage();
HandlePerComicLogic();
break;
case PageTypes.Subscriptions:
RenderSubscriptionsPage();
break;
case PageTypes.TagPage:
HandleHeaderModifications();
HandleTagPageLogic();
HandlePerComicLogic();
HandleInfiniteLoad();
break;
case PageTypes.IndexPage:
HandleHeaderModifications();
HandlePerComicLogic();
HandleInfiniteLoad();
break;
case PageTypes.OwnUserPage:
HandleHeaderModifications();
HandleOwnUserPageLogic();
HandlePerComicLogic();
break;
case PageTypes.SettingsPage:
HandleHeaderModifications();
HandleSettingsPageLogic();
break;
default:
HandleHeaderModifications();
HandlePerComicLogic();
break;
}
}
//#region Scoped functions
function SetCurrentPageType() {
currentPageType = null;
currentPagePath = window.location.pathname;
if (currentPagePath === "/") {
currentPageType = PageTypes.IndexPage;
}
else if (/^\/g\/\d+?\/\d+?\/$/.test(currentPagePath)) {
currentPageType = PageTypes.Reader;
}
else if (/^\/g\/\d+?\/$/.test(currentPagePath)) {
currentPageType = PageTypes.ComicInfo;
}
else if (currentPagePath.startsWith("/subscriptions/")) {
currentPageType = PageTypes.Subscriptions;
}
else if (
currentPagePath.startsWith("/users/") &&
$("nav .menu.right a[href^='/users/']")?.prop("href")?.includes(currentPagePath)
) {
currentPageType = PageTypes.OwnUserPage;
}
else if (
currentPagePath.startsWith("/artist/") ||
currentPagePath.startsWith("/group/") ||
currentPagePath.startsWith("/tag/") ||
currentPagePath.startsWith("/language/") ||
currentPagePath.startsWith("/category/")
) {
currentPageType = PageTypes.TagPage;
}
else if (currentPagePath.startsWith("/user/settings")) {
currentPageType = PageTypes.SettingsPage;
}
else {
currentPageType = PageTypes.Other;
}
//console.info("currentPageType: ", currentPageType);
return currentPageType;
}
function HandlePerComicLogic() {
if (mark_as_read_system_enabled)
MARSet = new Set(TryParseJSON(GM_getValue("MARArrayString", "[]"), []));
continuouslyObserveForElements(".cover", HandleCoverElem);
}
function HandleInfiniteLoad() {
let baseUrl = null;
function getBaseUrlForInfiniteLoad() {
if (baseUrl)
return Promise.resolve(baseUrl);
//if tag page
if (currentPageType == PageTypes.TagPage) {
//refresh Subs map if enabled
if (subscription_system_enabled)
SubsSlugIdMap = JSONArrayStringParseToMap(GM_getValue("SubArrayString", "[]"));
//get id from slug
return getSlugId(currentPagePath).then((id) => {
const sort = window.location.search?.split("sort=")?.[1]?.split("&")?.[0]?.trim() || "date";
baseUrl = `/api/v2/galleries/tagged?tag_id=${id}&sort=${sort}`;
return baseUrl;
});
}
//else index page
else {
baseUrl = "/api/v2/galleries";
return Promise.resolve(baseUrl);
}
}
if (infinite_load) {
waitForElement("section.pagination:visible:first > .page.current", () => {
//clear paginators so that the weird dynamic refreshing behaviour doesn't mess things up...
$("section.pagination > .page.current:not(:first-of-type)").each(function () {
$(this).removeClass("current");
});
const pagination = $("section.pagination:visible:first");
if ('IntersectionObserver' in window) {
if (pagination.length) {
new IntersectionObserver(() => {
getBaseUrlForInfiniteLoad().then(TryLoadMoreComicsForPage);
}, { threshold: 0 }).observe(pagination[0]);
//console.info("Started IntersectionObserver!");
}
else {
console.warn("NHI - Failed to get container for IntersectionObserver!");
}
} else {
console.warn("NHI - Browser does not appear to support IntersectionObserver! Infinite load will not work.");
}
// Theoretically if the page is so large that the paginator is visible from the beginning, it will never ENTER visibility, thus never triggering the above observer.
// Therefore, we will do a one time check here to load more if it's already in view.
// Also, the loader itself will recall itself if the paginator is still in view after first load (succeed or fail)
if (isInView(pagination)) {
getBaseUrlForInfiniteLoad().then(TryLoadMoreComicsForPage);
}
});
}
}
function TryLoadMoreComicsForPage(url) {
//don't start multiple loads at once
if (infiniteLoadInProgress)
return;
const lastestLoadedPage = Number($("section.pagination:visible:first > a.page.current")?.last()?.text()?.trim());
const lastPage = Number($("section.pagination:visible:first > a.last")?.attr("href")?.split("page=")?.[1]?.trim());
//don't try to load more if latest loaded page is last page
if (lastestLoadedPage >= lastPage)
return;
const galleryContainer = $(".container:has(.gallery):last");
//only start load if we get the target insert location and current page number
if (lastestLoadedPage && galleryContainer) {
const newPage = lastestLoadedPage + 1; //set page to load
infiniteLoadInProgress = true; //mark as load as ongoing
//start load
fetchUrl(`${url}${url.includes("?") ? "&" : "?"}page=${newPage}&per_page=25`).then((res) => {
//on success
//mark both the desltop and the mobile paginators
const loadedPageInDesktopPaginator = $(`section.desktop-pagination > a.page[href$='page=${newPage}']`);
if (loadedPageInDesktopPaginator?.length) {
loadedPageInDesktopPaginator.addClass("current");
}
else {
$(`section.desktop-pagination > .page[href$='page=${newPage - 1}']`).after(`<a href="/?page=${newPage}" class="page svelte-3clh5r current nhi-paginator-page">${newPage}</a>`);
}
const loadedPageInMobilePaginator = $(`section.mobile-pagination > a.page[href$='page=${newPage}']`);
if (loadedPageInMobilePaginator?.length) {
loadedPageInMobilePaginator.addClass("current");
}
else {
$(`section.mobile-pagination > .page[href$='page=${newPage - 1}']`).after(`<a href="/?page=${newPage}" class="page svelte-3clh5r current nhi-paginator-page">${newPage}</a>`);
}
//foreach new item
res.data.result.forEach((galleryItem) => {
const isEng = galleryItem.tag_ids.includes(12227);
const isChn = galleryItem.tag_ids.includes(29963);
const isJpn = galleryItem.tag_ids.includes(6346);
//skip: non-english galleries if remove_non_english == true, blacklisted galleries if remove_native_blacklisted == true, galleries that already exist on page
if ((!remove_non_english || isEng) && (!remove_native_blacklisted || !galleryItem.blacklisted) && !$(`a.cover[href*='${galleryItem.id}']`)?.length) {
//form the new element
const element = $(`
<div class="gallery ${isEng ? "lang-gb" : isChn ? "lang-cn" : isJpn ? "lang-jp" : ""} ${galleryItem.blacklisted ? "blacklisted" : ""}">
<a class="cover" href="/g/${galleryItem.id}/">
<img loading="lazy" style="position: relative;"
class="lazyload" src="https://t1.nhentai.net/galleries/${galleryItem.media_id}/thumb.webp"
data-load-attempt="1.webp"
data-media-id="${galleryItem.media_id}"
alt="${english_titles_for_infinite_load ? galleryItem.english_title || galleryItem.japanese_title : galleryItem.japanese_title || galleryItem.english_title}"><!---->
<div class="caption">${english_titles_for_infinite_load ? galleryItem.english_title || galleryItem.japanese_title : galleryItem.japanese_title || galleryItem.english_title}</div>
</a>
</div>
`);
//add load/error handlers to the thumbnails
element.find("img").on("error", function (e) {
//on error: try again with different urls (t1, t2, t3, t4, .jpg, .webp, .png)
const prevAttempt = $(this).attr("data-load-attempt");
const prevAttemptT = Number(prevAttempt.split(".")[0]);
const prevAttemptFileType = prevAttempt.split(".")[1];
const newAttemptT = prevAttemptT + (prevAttemptFileType == "png" ? 1 : 0); //New load attempt T is +1 if last load attempt fileType was png (last one)
const newAttemptFileType = prevAttemptFileType == "webp" ? "jpg" : prevAttemptFileType == "jpg" ? "png" : "webp"; //new attempt FileType is next in line from webp/jpg/png
const mediaId = $(this).attr("data-media-id");
if (newAttemptT >= 5) {
$(this).off("load").off("error");
console.warn("NHI - Failed to load thumbnail for comic id: " + $(this)?.closest("a")?.attr("href")?.replace(/\D/, "") || "unknown");
return;
}
const newSrc = `https://t${newAttemptT}.nhentai.net/galleries/${mediaId}/thumb.${newAttemptFileType}`;
$(this).attr("src", newSrc).attr("data-load-attempt", newAttemptT + "." + newAttemptFileType);
}).on("load", function (e) {
//success: stop load/error listeners
$(this).off("load").off("error");
});
//add the new element to page
galleryContainer.append(element);
}
});
if (isInView($("section.pagination:visible:first"))) {
setTimeout(() => {
TryLoadMoreComicsForPage(url); //try to load more if the paginator is still in view
}, 500);
}
infiniteLoadInProgress = false; //mark infinite load as no longer in progress
}).catch((res) => {
//do not clear infiniteLoadInProgress, so that we won't try to infinite load again until next refresh.
console.warn("NHI - InfiniteLoad error: " + res?.jqXHR?.status + " - Will stop trying until next refresh, to avoid spamming the API.");
});
}
}
function HandleCoverElem(coverElem) {
//Marked as Read
if (mark_as_read_system_enabled) {
const readTag = $(coverElem).find(".readTag");
const splitHref = "/g/" + $(coverElem).prop("href")?.split("/g/")?.[1]?.trim();
if (MARSet.has(splitHref)) {
$(coverElem).addClass("marked-as-read");
if (!($(readTag)?.length > 0))
$(coverElem).append("<div class='readTag'>READ</div>");
}
else {
$(coverElem).removeClass("marked-as-read");
if (($(readTag)?.length > 0))
$(readTag).remove();
}
}
//Remove Non English + Remove Native Blacklisted
if (remove_non_english || remove_native_blacklisted) {
const gallery = $(coverElem).closest(".gallery");
if (gallery?.length > 0) {
if ((remove_non_english && !gallery.hasClass("lang-gb")) || (remove_native_blacklisted && gallery.hasClass("blacklisted")))
gallery.hide();
else
gallery.show();
}
}
}
function HandleComicReaderAsNeeded() {
if (comic_reader_improved_zoom) {
zoom_level = Number(GM_getValue("zoom_level", 1.0));
let styleHandle;
function SetReaderImageScale(scale) {
infoWithTimestamp("SetReaderImageScale(scale): " + scale);
if (styleHandle)
$(styleHandle).remove();
styleHandle = GM_addStyle(`#image-container img {width: ${1280 * scale}px !important;}`);
$("section.reader-bar .zoom-level > .value")?.html(scale.toFixed(1));
GM_setValue("zoom_level", scale);
};
let prevVal = 1.0;
if (remember_zoom_level)
prevVal = zoom_level;
let curVal = prevVal;
const zoomOut = () => {
curVal = prevVal - 0.1;
if (curVal < 0.1)
curVal = 0.1;
SetReaderImageScale(curVal);
prevVal = curVal;
};
const zoomIn = () => {
curVal = prevVal + 0.1;
if (curVal > 3)
curVal = 3;
SetReaderImageScale(curVal);
prevVal = curVal;
}
SetReaderImageScale(curVal);
waitForElement("#image-container", () => {
SetReaderImageScale(curVal);
$('body').on('keydown', (e) => {
//console.info("body keydown");
if (e.key === '+') { zoomIn(); }
else if (e.key === '-') { zoomOut() }
});
$("section.reader-bar button.reader-zoom-out").click((e) => {
//console.info("click zoom out");
e.preventDefault();
e.stopPropagation();
zoomOut();
});
$("section.reader-bar button.reader-zoom-in").click((e) => {
//console.info("click zoom in");
e.preventDefault();
e.stopPropagation();
zoomIn();
});
}, { debounce: 50 });
}
}
function HandleComicInfoPage() {
//Mark as read system
if (mark_as_read_system_enabled) {
MARSet = new Set(TryParseJSON(GM_getValue("MARArrayString", "[]"), []));
waitForElement(".buttons", () => {
$("#nhi-mar-button").remove(); //remove old buttons
if (MARSet.has(currentPagePath)) //if item is marked as read (path == item. e.g. "/g/1234/")
$(".buttons").append(`
<a href="#" id="nhi-mar-button" class="btn btn-secondary btn-nhi-mark-as-unread">
<span style="vertical-align: middle;"><i class="fa fa-book-open"></i> Mark as unread</span>
</a>
`); //..add unmark button
else
$(".buttons").append(`
<a href="#" id="nhi-mar-button" class="btn btn-secondary btn-nhi-mark-as-read">
<span style="vertical-align: middle;"><i class="fa fa-book"></i> Mark as read</span>
</a>
`); //...add mark button
$("#nhi-mar-button").click(function (e) {
e.stopPropagation();
e.preventDefault();
//check if we are adding or deleting
const markingAsRead = $(this).hasClass("btn-nhi-mark-as-read");
//get the array again to make sure we have an up to date array (since other tabs could have modified it since loading this page)
MARSet = new Set(TryParseJSON(GM_getValue("MARArrayString", "[]"), []));
//add or delete
if (markingAsRead)
MARSet.add(currentPagePath);
else
MARSet.delete(currentPagePath);
//save changes
const MARArrayString = JSON.stringify([...MARSet]); //covert set to array string
GM_setValue("MARArrayString", MARArrayString); //save string
//update button
if (markingAsRead)
$(this).html(`
<span style="vertical-align: middle;"><i class="fa fa-book-open"></i> Mark as unread</span>
`).removeClass('btn-nhi-mark-as-read').addClass('btn-nhi-mark-as-unread');
else
$(this).html(`
<span style="vertical-align: middle;"><i class="fa fa-book"></i> Mark as read</span>
`).removeClass('btn-nhi-mark-as-unread').addClass('btn-nhi-mark-as-read');
});
});
}
//Subscriptions system
if (subscription_system_enabled) {
SubsSlugIdMap = JSONArrayStringParseToMap(GM_getValue("SubArrayString", "[]"));
waitForElement("#tags", () => {
$(".tag").each((i, el) => {
if (SubsSlugIdMap.has($(el).attr("href")))
$(el).addClass("subbedTag");
else
$(el).removeClass("subbedTag");
});
});
}
}
function HandleHeaderModifications() {
if (subscription_system_enabled && $("#header-subs-button")?.length <= 0) {
waitForElement(".menu.right", () => {
//Add Subscriptions button in the header to take user to custom Subscriptions page
$(".menu.right").prepend('<li title="Subscriptions"><a id="header-subs-button" href="/subscriptions/"><i class="fa fa-heartbeat"></i><span> Subscriptions</span></a></li>');
});
}
}
function RenderSubscriptionsPage() {
SubsSlugIdMap = JSONArrayStringParseToMap(GM_getValue("SubArrayString", "[]"));
waitForElement("#app > .error-page", () => {
UpdateSubsWithIDsAsNeeded();
//create page skeleton
$("head title").html('Subscriptions').prop("style", "font-size: 3.5em;");
$("#app").html(
`<div id="content" class="nhi-subscriptions-page">
<h1>Subscriptions</h1>
<h2>Artists</h2>
<div class="container tag-container artist-section"></div>
<h2>Groups</h2>
<div class="container tag-container group-section"></div>
<h2>Tags</h2>
<div class="container tag-container tag-section"></div>
<h2>Languages</h2>
<div class="container tag-container language-section"></div>
<h2>Categories</h2>
<div class="container tag-container category-section"></div>
</div>`
);
//add subs to page
for (const subItem of SubsSlugIdMap) {
$(`.container.${subItem[0].split("/")[1]}-section`).append(`<a class="tag subs-page-tag" href="${subItem[0]}"><span class="name">${subItem[0].split("/")[2]}</span><span class="count nhi-subs-page-count" data-for-id="${subItem[1]}">...</span></a>`);
}
fetchUrl(`/api/v2/tags/ids?ids=${encodeURIComponent([...SubsSlugIdMap.values()].filter(v => v != null && v !== '').join(","))}`).then((res) => {
res.data.forEach((tagObj) => {
$(`.nhi-subs-page-count[data-for-id="${tagObj.id}"]`)?.text(tagObj.count || "-");
});
});
ReloadOnClickAfterTimeout(".subs-page-tag");
//hide empty sections
$(".nhi-subscriptions-page > .tag-container").each((i, el) => {
if ($(el).is(":empty")) {
$(el).hide();
$(el).prev("h2").hide();
}
});
});
}
function HandleTagPageLogic() {
if (subscription_system_enabled) {
SubsSlugIdMap = JSONArrayStringParseToMap(GM_getValue("SubArrayString", "[]"));
if (currentPagePath) {
waitForElement("h1 > .tag", () => {
//workaround for bug where if you change the sort method without a hard refresh, it apppears the API returns the results from the previous sort method (even though we send the API the right params...)
ReloadOnClickAfterTimeout("h1 + .sort a");
$("#nhi-subscribe-button").remove(); //remove possible old button
if (SubsSlugIdMap.has(currentPagePath))
$(".tag").after('<a href="#" id="nhi-subscribe-button" class="btn btn-secondary btn-nhi-unsub"><span style="vertical-align: middle;">Unsubscribe</span></a>'); //...add unsub button
else
$(".tag").after('<a href="#" id="nhi-subscribe-button" class="btn btn-secondary btn-nhi-sub"><span style="vertical-align: middle;">Subscribe</span></a>'); //...add sub button
//on click
$("#nhi-subscribe-button").click(function (e) {
e.preventDefault();
e.stopPropagation();
const clickedButton = $(this);
//get the array again to make sure we have an up to date array (since other tabs could have modified it since loading this page)
SubsSlugIdMap = JSONArrayStringParseToMap(GM_getValue("SubArrayString", "[]"));
//get slugId (try Subs first, if not found, fetch via API)
getSlugId(currentPagePath).then(slugId => {
//check if we are subbing or unsubbing
const isSubbing = clickedButton.hasClass("btn-nhi-sub");
//sub or unsub
if (isSubbing)
SubsSlugIdMap.set(currentPagePath, slugId);
else
SubsSlugIdMap.delete(currentPagePath);
//save changes
const SubArrayString = JSON.stringify([...SubsSlugIdMap]); //covert map to array string
GM_setValue("SubArrayString", SubArrayString); //save string
//update button
if (isSubbing)
clickedButton.html('<span style="vertical-align: middle;">Unsubscribe</span>').removeClass('btn-nhi-sub').addClass('btn-nhi-unsub');
else
clickedButton.html('<span style="vertical-align: middle;">Subscribe</span>').removeClass('btn-nhi-unsub').addClass('btn-nhi-sub');
});
});
});
}
}
}
function HandleSettingsPageLogic() {
const renderSettingsPage = (open) => {
const existing = $(".nhi-settings-block");
//if there and in required state, do nothing further (return)
if (existing?.length > 0 &&
((open && existing.hasClass("open")) ||
(!open && !existing.hasClass("open")))
) return;
existing?.remove();
//insert NHI settings HTML
$(".acc-section.acc-danger").before(`
<div class="acc-section svelte-x30ryz nhi-settings-block ${open ? "open" : ""}">
<button class="acc-header svelte-x30ryz">
<span><i class="fa fa-desktop"></i> NHI Settings</span> <i class="fa fa-chevron-${open ? "up" : "down"} acc-arrow svelte-x30ryz"></i>
</button>
${open ? `
<div class="acc-body svelte-x30ryz nhi-settings-page">
<div class="nhi-settings-main">
<form id="nhi-settings-form">
<div class="nhi-settings-section">
<h2>General Settings</h2>
<label><input name="block_extra_ads" type="checkbox" ${block_extra_ads ? "checked" : ""}><span>Block Extra Ads</span></label>
</div>
<div class="nhi-settings-section">
<h2>Non-English Settings</h2>
<label><input name="remove_non_english" type="checkbox" ${remove_non_english ? "checked" : ""}><span>Remove Non-English</span></label>
<label><input name="partially_fade_all_non_english" type="checkbox" ${partially_fade_all_non_english ? "checked" : ""}><span>Partially Fade Non-English</span></label>
<label><input name="non_english_fade_opacity" type="number" min="0" max="1" step="0.1" placeholder="0.3 - 1.0" value="${non_english_fade_opacity}"><span>Fade Opacity</span></label>
</div>
<div class="nhi-settings-section">
<h2>Thumbnail Settings</h2>
<label><input name="browse_thumbnail_width" type="number" min="0" placeholder="0" value="${browse_thumbnail_width}"><span>Thumbnail Width</span></label>
<label><input name="browse_thumnail_container_width" type="number" min="0" placeholder="0" value="${browse_thumnail_container_width}"><span>Thumbnail Container Width</span></label>
</div>
<div class="nhi-settings-section">
<h2>Comic Reader Settings</h2>
<label><input name="comic_reader_improved_zoom" type="checkbox" ${comic_reader_improved_zoom ? "checked" : ""}><span>Improved Zoom</span></label>
<label><input name="remember_zoom_level" type="checkbox" ${remember_zoom_level ? "checked" : ""}><span>Remember Zoom Level</span></label>
</div>
<div class="nhi-settings-section">
<h2>Mark As Read Settings</h2>
<label><input name="mark_as_read_system_enabled" type="checkbox" ${mark_as_read_system_enabled ? "checked" : ""}><span>Enabled</span></label>
<label><input name="marked_as_read_fade_opacity" type="number" min="0" max="1" step="0.1" placeholder="0.3 - 1.0" value="${marked_as_read_fade_opacity}"><span>Fade Opacity</span></label>
<label><input name="read_tag_font_size" type="number" min="0" placeholder="15" value="${read_tag_font_size}"><span>Read Tag Font Size</span></label>
</div>
<div class="nhi-settings-section">
<h2>Subscription Settings</h2>
<label><input name="subscription_system_enabled" type="checkbox" ${subscription_system_enabled ? "checked" : ""}><span>Enabled</span></label>
</div>
<div class="nhi-settings-section">
<h2>Native Blacklist Settings</h2>
<label><input name="remove_native_blacklisted" type="checkbox" ${remove_native_blacklisted ? "checked" : ""}><span>Remove blacklisted comics</span></label>
</div>
<div class="nhi-settings-section nhi-settings-save-section">
<input id="saveButt" style="line-height: 30px; height: 30px;" type="submit" class="btn btn-primary" value="Save and Refresh" />
</div>
</form>
<hr />
<div class="nhi-settings-export-container">
<div class="nhi-settings-section">
<h2 style="margin-bottom: 0;">Import/Export Settings and Data</h2>
<p>During import, you can also optionally/interactively combine current data and new data.</p>
</br>
<button id="exportButt" style="line-height: 30px; height: 30px;" type="button" class="btn btn-primary">Export</button>
<button id="importButt" style="line-height: 30px; height: 30px;" type="button" class="btn btn-primary">Import</button>
<input id="NHIImportFile" type="file" style="display: none;">
</div>
</div>
</div>
</div>
` : ``}
</div>
`);
//settings section header click event
$(".nhi-settings-block > .acc-header").on("click", (e) => {
e.preventDefault();
e.stopPropagation();
renderSettingsPage(!open);
});
//if NHI settings open, add event handlers for it and ensure Sets are up to date
if (open) {
SubsSlugIdMap = JSONArrayStringParseToMap(GM_getValue("SubArrayString", "[]"));
MARSet = new Set(TryParseJSON(GM_getValue("MARArrayString", "[]"), []));
//Handle disables/fadeouts
//set onChange event handlers
$(".nhi-settings-section input[name='remove_non_english']").on("change", (e) => {
$(".nhi-settings-section input[name='partially_fade_all_non_english']").prop("disabled", e.target.checked).closest("label").fadeTo(200, e.target.checked ? 0.5 : 1);
$(".nhi-settings-section input[name='non_english_fade_opacity']").prop("disabled", e.target.checked).closest("label").fadeTo(200, e.target.checked ? 0.5 : 1);
});
$(".nhi-settings-section input[name='comic_reader_improved_zoom']").on("change", (e) => {
$(".nhi-settings-section input[name='remember_zoom_level']").prop("disabled", !e.target.checked).closest("label").fadeTo(200, !e.target.checked ? 0.5 : 1);
});
$(".nhi-settings-section input[name='mark_as_read_system_enabled']").on("change", (e) => {
$(".nhi-settings-section input[name='marked_as_read_fade_opacity']").prop("disabled", !e.target.checked).closest("label").fadeTo(200, !e.target.checked ? 0.5 : 1);
$(".nhi-settings-section input[name='read_tag_font_size']").prop("disabled", !e.target.checked).closest("label").fadeTo(200, !e.target.checked ? 0.5 : 1);
});
//trigger all the onChange event handlers to setup initial states
$(`
.nhi-settings-section input[name='remove_non_english'],
.nhi-settings-section input[name='comic_reader_improved_zoom'],
.nhi-settings-section input[name='mark_as_read_system_enabled']
`).trigger("change");
//Handle save
$("#nhi-settings-form").on("submit", (e) => {
e.preventDefault();
const data = e.target;
GM_setValue("block_extra_ads", data.block_extra_ads.checked);
GM_setValue("remove_non_english", data.remove_non_english.checked);
GM_setValue("partially_fade_all_non_english", data.partially_fade_all_non_english.checked);
GM_setValue("non_english_fade_opacity", data.non_english_fade_opacity.value);
GM_setValue("browse_thumbnail_width", data.browse_thumbnail_width.value);
GM_setValue("browse_thumnail_container_width", data.browse_thumnail_container_width.value);
GM_setValue("comic_reader_improved_zoom", data.comic_reader_improved_zoom.checked);
GM_setValue("remember_zoom_level", data.remember_zoom_level.checked);
GM_setValue("mark_as_read_system_enabled", data.mark_as_read_system_enabled.checked);
GM_setValue("marked_as_read_fade_opacity", data.marked_as_read_fade_opacity.value);
GM_setValue("read_tag_font_size", data.read_tag_font_size.value);
GM_setValue("subscription_system_enabled", data.subscription_system_enabled.checked);
GM_setValue("remove_native_blacklisted", data.remove_native_blacklisted.checked);
location.reload();
});
//Handle Export
$("#exportButt").click((e) => {
e.preventDefault();
const jsonObj = {
"block_extra_ads": GM_getValue("block_extra_ads", true),
"remove_non_english": GM_getValue("remove_non_english", false),
"partially_fade_all_non_english": GM_getValue("partially_fade_all_non_english", true),
"non_english_fade_opacity": GM_getValue("non_english_fade_opacity", 0.3),
"browse_thumbnail_width": GM_getValue("browse_thumbnail_width", 0),
"browse_thumnail_container_width": GM_getValue("browse_thumnail_container_width", 0),
"comic_reader_improved_zoom": GM_getValue("comic_reader_improved_zoom", true),
"remember_zoom_level": GM_getValue("remember_zoom_level", true),
"mark_as_read_system_enabled": GM_getValue("mark_as_read_system_enabled", true),
"marked_as_read_fade_opacity": GM_getValue("marked_as_read_fade_opacity", 0.3),
"read_tag_font_size": GM_getValue("read_tag_font_size", 15),
"subscription_system_enabled": GM_getValue("subscription_system_enabled", true),
"remove_native_blacklisted": GM_getValue("remove_native_blacklisted", true),
"MARArrayString": GM_getValue("MARArrayString", "[]"),
"SubArrayString": GM_getValue("SubArrayString", "[]")
};
SaveToFile(`NHI-Backup_${new Date().toISOString().replace(/:/g, "-")}.nhi2`, JSON.stringify(jsonObj, null, 2));
});
//Handle Import
$("#importButt").click((e) => {
e.preventDefault();
$("#NHIImportFile").animate({ height: 'toggle' }, 200);
});
$("#NHIImportFile").change((event) => {
if (typeof window.FileReader !== 'function')
throw ("The file API isn't supported on this browser.");
const input = event.target;
if (!input)
throw ("The browser does not properly implement the event object");
if (!input.files)
throw ("This browser does not support the `files` property of the file input.");
const file = input.files[0];
if (!file.name.endsWith(".nhi") && !file.name.endsWith(".nhi2")) {
$("#NHIImportFile").val('');
throw ("Invalid file extension");
}
const fr = new FileReader();
fr.onload = (ev) => {
const importedData = ev.target.result;
if (importedData != null && confirm("File received. Import this file?")) {
if (file.name.endsWith(".nhi2"))
ImportNewNHIFile(importedData);
else
ImportOldNHIFile(importedData);
}
else
$("#NHIImportFile").val('');
};
fr.readAsText(file);
});
}
};
waitForElement(".acc-section.acc-danger", () => {
renderSettingsPage((!!($(".nhi-settings-block")?.hasClass("open")) || location.hash.includes("nhi-settings")));
});
}
function HandleOwnUserPageLogic() {
if (mark_as_read_system_enabled) {
MARSet = new Set(TryParseJSON(GM_getValue("MARArrayString", "[]"), []));
waitForElement(".user-info > .btn-group", () => {
$(".nhi-comics-mar").remove(); //remove old if found
$(".user-info > .btn-group").before(`<p class="nhi-comics-mar"><b>Comics marked as read: </b>${MARSet.size}</p>`); //add number of comics read to page
});
}
}
function ImportNewNHIFile(dataAsString) {
const jsonObj = JSON.parse(dataAsString);
const changingSettings = [];
if (block_extra_ads !== jsonObj.block_extra_ads)
changingSettings.push(`Block Extra Ads - From ${block_extra_ads} to ${jsonObj.block_extra_ads}`);
if (remove_non_english !== jsonObj.remove_non_english)
changingSettings.push(`Remove Non-English - From ${remove_non_english} to ${jsonObj.remove_non_english}`);
if (partially_fade_all_non_english !== jsonObj.partially_fade_all_non_english)
changingSettings.push(`Partially Fade Non-English - From ${partially_fade_all_non_english} to ${jsonObj.partially_fade_all_non_english}`);
if (non_english_fade_opacity !== jsonObj.non_english_fade_opacity)
changingSettings.push(`Non-English Fade Opacity - From ${non_english_fade_opacity} to ${jsonObj.non_english_fade_opacity}`);
if (browse_thumbnail_width !== jsonObj.browse_thumbnail_width)
changingSettings.push(`Browse Section Thumbnail Width - From ${browse_thumbnail_width} to ${jsonObj.browse_thumbnail_width}`);
if (browse_thumnail_container_width !== jsonObj.browse_thumnail_container_width)
changingSettings.push(`Browse Section Thumbnail Container Width - From ${browse_thumnail_container_width} to ${jsonObj.browse_thumnail_container_width}`);
if (comic_reader_improved_zoom !== jsonObj.comic_reader_improved_zoom)
changingSettings.push(`Improved Comic Reader Zoom - From ${comic_reader_improved_zoom} to ${jsonObj.comic_reader_improved_zoom}`);
if (remember_zoom_level !== jsonObj.remember_zoom_level)
changingSettings.push(`Remember Zoom Level - From ${remember_zoom_level} to ${jsonObj.remember_zoom_level}`);
if (mark_as_read_system_enabled !== jsonObj.mark_as_read_system_enabled)
changingSettings.push(`Mark As Read System Enabled - From ${mark_as_read_system_enabled} to ${jsonObj.mark_as_read_system_enabled}`);
if (marked_as_read_fade_opacity !== jsonObj.marked_as_read_fade_opacity)
changingSettings.push(`Marked As Read Fade Opacity - From ${marked_as_read_fade_opacity} to ${jsonObj.marked_as_read_fade_opacity}`);
if (read_tag_font_size !== jsonObj.read_tag_font_size)
changingSettings.push(`Read Tag Font Size - From ${read_tag_font_size} to ${jsonObj.read_tag_font_size}`);
if (subscription_system_enabled !== jsonObj.subscription_system_enabled)
changingSettings.push(`Subscription System Enabled - From ${subscription_system_enabled} to ${jsonObj.subscription_system_enabled}`);
if (remove_native_blacklisted !== jsonObj.remove_native_blacklisted)
changingSettings.push(`Remove Native Blacklisted - From ${remove_native_blacklisted} to ${jsonObj.remove_native_blacklisted}`);
if (changingSettings.length > 0) {
const changingSettingsString = "The following settings have changes in the imported file:\n\n" +
changingSettings.join("\n") +
"\n\nWould you like to import these changes?";
if (confirm(changingSettingsString)) {
GM_setValue("block_extra_ads", jsonObj.block_extra_ads);
GM_setValue("remove_non_english", jsonObj.remove_non_english);
GM_setValue("partially_fade_all_non_english", jsonObj.partially_fade_all_non_english);
GM_setValue("non_english_fade_opacity", jsonObj.non_english_fade_opacity);
GM_setValue("browse_thumbnail_width", jsonObj.browse_thumbnail_width);
GM_setValue("browse_thumnail_container_width", jsonObj.browse_thumnail_container_width);
GM_setValue("comic_reader_improved_zoom", jsonObj.comic_reader_improved_zoom);
GM_setValue("remember_zoom_level", jsonObj.remember_zoom_level);
GM_setValue("mark_as_read_system_enabled", jsonObj.mark_as_read_system_enabled);
GM_setValue("marked_as_read_fade_opacity", jsonObj.marked_as_read_fade_opacity);
GM_setValue("read_tag_font_size", jsonObj.read_tag_font_size);
GM_setValue("subscription_system_enabled", jsonObj.subscription_system_enabled);
GM_setValue("remove_native_blacklisted", jsonObj.remove_native_blacklisted);
}
}
HandleImportedCollection(MARSet, jsonObj.MARArrayString, "Marked As Read", "MARArrayString");
HandleImportedCollection(SubsSlugIdMap, jsonObj.SubArrayString, "Subscriptions", "SubArrayString");
location.reload();
}
function ImportOldNHIFile(dataAsString) {
const dataString = dataAsString.replace(/(\r?\n|\r)/g, "").trim(); //remove newlines and trim
if (dataString.indexOf("|||||") < 0) {
alert("invalid data");
return;
}
const dataArr = dataString.split("|||||");
if (dataArr.length > 0) {
GM_setValue("SubArrayString", dataArr[0]);
console.log("NHI - SubArrayString imported");
}
if (dataArr.length > 1) {
GM_setValue("MARArrayString", dataArr[1]);
console.log("NHI - MARArrayString imported");
}
if (dataArr.length > 2) {
GM_setValue("BlockTagArrayString", dataArr[2]);
console.log("NHI - BlockTagArrayString imported");
}
if (dataArr.length > 3) {
GM_setValue("remove_non_english", (String(dataArr[3]) == "true"));
console.log("NHI - remove_non_english imported");
}
if (dataArr.length > 4) {
GM_setValue("partially_fade_all_non_english", (String(dataArr[4]) == "true"));
console.log("NHI - partially_fade_all_non_english imported");
}
if (dataArr.length > 5) {
GM_setValue("non_english_fade_opacity", dataArr[5]);
console.log("NHI - non_english_fade_opacity imported");
}
if (dataArr.length > 6) {
GM_setValue("load_high_quality_browse_thumbnails", (String(dataArr[6]) == "true"));
console.log("NHI - load_high_quality_browse_thumbnails imported");
}
if (dataArr.length > 7) {
GM_setValue("browse_thumbnail_width", dataArr[7]);
console.log("NHI - browse_thumbnail_width imported");
}
if (dataArr.length > 8) {
GM_setValue("browse_thumnail_container_width", dataArr[8]);
console.log("NHI - browse_thumnail_container_width imported");
}
if (dataArr.length > 9) {
GM_setValue("load_high_quality_pages_thumbnails", (String(dataArr[9]) == "true"));
console.log("NHI - load_high_quality_pages_thumbnails imported");
}
if (dataArr.length > 10) {
GM_setValue("pages_thumbnail_width", dataArr[10]);
console.log("NHI - pages_thumbnail_width imported");
}
if (dataArr.length > 11) {
GM_setValue("pages_thumnail_container_width", dataArr[11]);
console.log("NHI - pages_thumnail_container_width imported");
}
if (dataArr.length > 12) {
GM_setValue("max_image_reload_attempts", dataArr[12]);
console.log("NHI - max_image_reload_attempts imported");
}
if (dataArr.length > 13) {
GM_setValue("mark_as_read_system_enabled", (String(dataArr[13]) == "true"));
console.log("NHI - mark_as_read_system_enabled imported");
}
if (dataArr.length > 14) {
GM_setValue("marked_as_read_fade_opacity", dataArr[14]);
console.log("NHI - marked_as_read_fade_opacity imported");
}
if (dataArr.length > 15) {
GM_setValue("read_tag_font_size", dataArr[15]);
console.log("NHI - read_tag_font_size imported");
}
if (dataArr.length > 16) {
GM_setValue("subscription_system_enabled", (String(dataArr[16]) == "true"));
console.log("NHI - subscription_system_enabled imported");
}
if (dataArr.length > 17) {
GM_setValue("version_grouping_enabled", (String(dataArr[17]) == "true"));
console.log("NHI - version_grouping_enabled imported");
}
if (dataArr.length > 18) {
GM_setValue("version_grouping_filter_brackets", (String(dataArr[18]) == "true"));
console.log("NHI - version_grouping_filter_brackets imported");
}
if (dataArr.length > 19) {
GM_setValue("auto_group_on_page_comics", (String(dataArr[19]) == "true"));
console.log("NHI - auto_group_on_page_comics imported");
}
if (dataArr.length > 20) {
GM_setValue("comic_reader_improved_zoom", (String(dataArr[20]) == "true"));
console.log("NHI - comic_reader_improved_zoom imported");
}
if (dataArr.length > 21) {
GM_setValue("remember_zoom_level", (String(dataArr[21]) == "true"));
console.log("NHI - remember_zoom_level imported");
}
if (dataArr.length > 22) {
GM_setValue("zoom_level", Number(dataArr[22]));
console.log("NHI - zoom_level imported");
}
if (dataArr.length > 23) {
GM_setValue("tag_blocking_enabled", (String(dataArr[23]) == "true"));
console.log("NHI - tag_blocking_enabled imported");
}
if (dataArr.length > 24) {
GM_setValue("tag_blocking_fade_only", (String(dataArr[24]) == "true"));
console.log("NHI - tag_blocking_fade_only imported");
}
if (dataArr.length > 25) {
GM_setValue("infinite_load", (String(dataArr[25]) == "true"));
console.log("NHI - infinite_load imported");
}
location.reload();
}
function AddStylesheetsAsNeeded() {
if (partially_fade_all_non_english) {
GM_addStyle(`
/* NHI - partially_fade_all_non_english */
.gallery:not(.lang-gb) > .cover > img,
.gallery:not(.lang-gb) > .cover > .caption
{
opacity: ${non_english_fade_opacity};
}
`);
}
if (comic_reader_improved_zoom) {
GM_addStyle(`
/* NHI - comic_reader_improved_zoom*/
html.reader #image-container img { max-width: 100% !important; }
`);
}
if (mark_as_read_system_enabled) {
GM_addStyle(`
/* NHI - mark_as_read_system_enabled*/
.marked-as-read > img, .marked-as-read > .caption {
opacity: ${marked_as_read_fade_opacity};
}
.readTag {
border-radius: 10px;
padding: 5px 10px;
position: absolute;
background-color: rgba(0,0,0,.7);
color: rgba(255,255,255,.8);
bottom: 7.5px;
right: 7.5px;
font-size: ${read_tag_font_size}px;
font-weight: 900;
opacity: 1;
}
#info >.buttons > .btn-nhi-mark-as-read, #info >.buttons > .btn-nhi-mark-as-read:visited {
background-color: #3d9e48;
}
#info >.buttons > .btn-nhi-mark-as-read:active, #info >.buttons > .btn-nhi-mark-as-read:hover {
background-color: #52bc5e;
}
#info >.buttons > .btn-nhi-mark-as-unread, #info >.buttons > .btn-nhi-mark-as-unread:visited {
background-color: rgb(218, 53, 53);
}
#info >.buttons > .btn-nhi-mark-as-unread:active, #info >.buttons > .btn-nhi-mark-as-unread:hover {
background-color: #e26060;
}
.gallery {
position: relative;
}
`);
}
if (subscription_system_enabled) {
GM_addStyle(`
/* NHI - subscription_system_enabled */
#tags .subbedTag * {
background: unset;
}
#tags .subbedTag, #tags .subbedTag:visited {
background-color: #2c5030;
}
#tags .subbedTag:active, #tags .subbedTag:hover {
background-color: #416144;
}
#nhi-subscribe-button.btn-nhi-sub, #nhi-subscribe-button.btn-nhi-sub:visited {
background-color: #3d9e48;
}
#nhi-subscribe-button.btn-nhi-sub:active, #nhi-subscribe-button.btn-nhi-sub:hover {
background-color: #52bc5e;
}
#nhi-subscribe-button.btn-nhi-unsub, #nhi-subscribe-button.btn-nhi-unsub:visited {
background-color: rgb(218, 53, 53);
}
#nhi-subscribe-button.btn-nhi-unsub:active, #nhi-subscribe-button.btn-nhi-unsub:hover {
background-color: #e26060;
}
#sub-content ul {
text-align: left;
}
#sub-content li {
box-sizing: border-box;
display: inline-block;
width: 25%;
text-align: center;
padding: 5px 20px;
}
#nhi-subscribe-button {
height: auto;
line-height: initial;
cursor: pointer;
font-size: 0.5em;
padding: 6px 12px;
}
@media only screen and (max-width: 1345px) {
.menu.right a[href^='/logout/'] span { display: none; }
}
@media only screen and (max-width: 1270px) {
.menu.right a[href^='/users/'] span { display: none; }
}
@media only screen and (max-width: 670px) {
.menu.right a[href='/favorites/'] span { display: none; }
}
@media only screen and (max-width: 600px) {
.menu.right a[href^='/logout/'] span { display: inline; }
.menu.right a[href^='/users/'] span { display: inline; }
.menu.right a[href='/favorites/'] span { display: inline; }
}
.nhi-subscriptions-page > .tag-container {
margin-bottom: 40px;
}
`);
}
if (block_extra_ads) {
GM_addStyle(`
/* NHI - block_extra_ads */
nav ul.menu.left > li:has(a[href^="//tsyndicate.com"]) {
display: none;
}
`);
}
if (browse_thumnail_container_width > 0) {
GM_addStyle(`
/* NHI - browse_thumnail_container_width */
.container, .thumbs {
width: ${browse_thumnail_container_width}px;
max-width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.container h3, .container h2, .container h1 {
width: 100%;
}
.gallery {
height: fit-content;
}
#bigcontainer > #cover,
#bigcontainer > #info-block {
width: unset;
min-width: 350px;
flex-basis: 350px;
}
#bigcontainer > #cover {
flex-grow: 1;
}
#bigcontainer > #info-block {
flex-grow: 3;
}
#comment-post-container > .row,
#comments {
flex-basis: 100%;
}
`);
}
if (browse_thumbnail_width > 0) {
GM_addStyle(`
/* NHI - browse_thumbnail_width */
.container > div.gallery,
.container > div.gallery-favorite,
.thumbs > .thumb-container {
width: ${browse_thumbnail_width}px;
}
.thumbs > .thumb-container > .gallerythumb,
.thumbs > .thumb-container > .gallerythumb > img {
width: 100%;
}
`);
}
if (comment_improvements) {
//make comments not take up the whole container if they don't need to (no visual difference really, but vanilla it feels stupid that you hover nowhere near the comment, but the comment link catches it anyway)
//on hover comments coloring (like user link) to make it more clear the comments themselves are links too
GM_addStyle(`
/* NHI - comment_improvements */
.comment { width: fit-content; }
a.comment-link:hover,
a.comment-link:focus-visible {
color: var(--accent);
}
`);
}
//NHI Settings page
if (true) {
GM_addStyle(`
#nhi-nh-settings-button {
background-color: #1b2a37;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 16px;
cursor: pointer;
transition: color 0.3s ease, filter 0.3s ease;
}
#nhi-nh-settings-button:hover,
#nhi-nh-settings-button:active {
color: #ed2553;
filter: brightness(1.4);
}
#nhi-nh-settings-button > img {
height: 25px;
}
.nhi-settings-main {
margin-bottom: 50px;
}
.nhi-settings-page {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
max-width: 100%;
}
.nhi-settings-page .nhi-settings-section {
text-align: left;
display: block;
padding: 5px;
}
.nhi-settings-page .nhi-settings-section.nhi-settings-save-section {
padding-top: 15px;
display: flex;
align-items: center;
}
.nhi-settings-page #saveButt {
background-color: green;
margin-right: 10px;
}
.nhi-settings-page .nhi-settings-export-container .nhi-settings-section {
padding: 0;
}
.nhi-settings-page .nhi-settings-section h2 {
margin: 5px 0;
}
.nhi-settings-page .nhi-settings-section label {
margin: 5px;
}
.nhi-settings-page .nhi-settings-section label input {
margin-right: 5px;
}
.nhi-settings-page .nhi-settings-section input[type="number"],
.nhi-settings-page .nhi-settings-section input[type="text"] {
width: 80px;
height: 25px;
border-radius: 3px;
text-align: center;
padding: 0;
}
.nhi-settings-page .nhi-settings-section sub {
display: block;
line-height: 1em;
}
.nhi-settings-page a {
color: lightblue !important;
text-decoration: underline;
cursor: pointer;
}
.nhi-settings-export-container p {
margin: 0;
}
#NHIImportFile {
display: block;
margin-top: 5px;
}
`);
}
}
function getSlugId(slug) {
return new Promise((resolve, reject) => {
const cached = SubsSlugIdMap.get(slug);
if (cached) return resolve(cached);
fetchUrl("/api/v2/tags" + slug)
.then(res => {
const id = res && res.data && res.data.id;
if (id) {
SubsSlugIdMap.set(slug, id);
resolve(id);
} else {
reject(new Error(`No id in response for slug: ${slug}`));
}
})
.catch(reject);
});
}
function UpdateSubsWithIDsAsNeeded() {
//timer to debounce saves
let saveTimer = null;
function scheduleSave() {
if (saveTimer) return;
saveTimer = setTimeout(() => {
GM_setValue("SubArrayString", JSON.stringify([...SubsSlugIdMap]));
saveTimer = null;
}, 250);
}
//for each item missing an id...
const slugIdPromises = [];
SubsSlugIdMap.forEach((id, slug) => {
if (!id || id == "") {
//...fetch id...
slugIdPromises.push(fetchUrl("/api/v2/tags" + slug, (data) => {
//...and on success update the map and trigger a debounced save (save at most twice a second)
SubsSlugIdMap.set(slug, data.id);
scheduleSave();
}));
}
});
if (slugIdPromises.length > 0) {
alert(`NHI detected that you have subscriptions saved to your userdata that are missing IDs.\n` +
`Those are now being fetched and saved to your data.\n\n` +
`You can close this alert. You will get another after the process is complete.\n\n` +
`You should only see this if you are an old user, as any subscriptions made with the current version of NHI will save the IDs when subscribing.\n` +
`This also means that after all your subs have had their IDs fetched, you should not see this alert again.`);
Promise.allSettled(slugIdPromises).then((results) => {
//clear out scheduled saves
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
//final save changes
const SubArrayString = JSON.stringify([...SubsSlugIdMap]);
GM_setValue("SubArrayString", SubArrayString);
const successes = results.filter(r => r.status === 'fulfilled').length;
alert(`NHI Subs ID fetching is now done.\n ${slugIdPromises.length > successes ?
`Out of ${slugIdPromises.length} missing IDs, only ${successes} were successfully fetched.` +
`This might be due to API rate-limiting. (calls limited to 30/minute/IP) The rest will be fetched the next time you visit this page.` :
`All missing IDs successfully fetched.`}`);
});
}
}
function waitForElement(selector, callback, { root, debounce = 100 } = {}) {
if (_waitForElementObservers[selector]) {
const prev = _waitForElementObservers[selector];
clearTimeout(prev._debounce);
prev.disconnect();
delete _waitForElementObservers[selector];
}
const $existing = $(selector);
if ($existing.length) {
callback($existing);
return;
}
const observer = new MutationObserver((mutations) => {
const anyAdded = mutations.some(m => m.addedNodes.length > 0);
if (!anyAdded) return;
const check = () => {
const $el = $(selector);
if (!$el.length) return;
observer.disconnect();
delete _waitForElementObservers[selector];
callback($el);
};
if (debounce > 0) {
clearTimeout(observer._debounce);
observer._debounce = setTimeout(check, debounce);
} else {
check();
}
});
observer.observe(root ?? document.body, { childList: true, subtree: true });
_waitForElementObservers[selector] = observer;
}
function continuouslyObserveForElements(selector, callback) {
if (_continuousObservers[selector]) {
_continuousObservers[selector].disconnect();
delete _continuousObservers[selector];
}
// Fire for any already-existing matches
$(selector).each((_, el) => callback($(el)));
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
// Check if the node itself matches
if ($(node).is(selector)) callback($(node));
// Check any descendants within the added node
$(node).find(selector).each((_, el) => callback($(el)));
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
_continuousObservers[selector] = observer;
}
function waitForSwap(selector, callback, debounce = 100) {
if (_waitForSwapObservers[selector]) {
const prev = _waitForSwapObservers[selector];
clearTimeout(prev._debounce);
prev.disconnect();
delete _waitForSwapObservers[selector];
}
const el = $(selector)[0];
if (!el) return;
const observer = new MutationObserver(() => {
clearTimeout(observer._debounce);
observer._debounce = setTimeout(() => {
callback($(selector));
}, debounce);
});
observer.observe(el, { childList: true, subtree: true, characterData: true });
_waitForSwapObservers[selector] = observer;
}
//#endregion
})();
//#region Standalone functions
/**
* Detects dynamic page navigation events (History API + hashchange)
* and fires a callback on every page change.
*
* @param {Function} callback - Called on navigation with { url, type }
* @returns {{ destroy: Function }} - Call destroy() to remove all listeners
*/
function onPageChange(callback) {
if (typeof callback !== 'function') {
throw new Error('onPageChange: callback must be a function');
}
// Patch history methods (pushState / replaceState don't fire native events)
const _push = history.pushState.bind(history);
const _replace = history.replaceState.bind(history);
history.pushState = function (...args) {
_push(...args);
$(window).trigger('locationchange', { url: location.href, type: 'pushState' });
};
history.replaceState = function (...args) {
_replace(...args);
$(window).trigger('locationchange', { url: location.href, type: 'replaceState' });
};
// Listen for all navigation sources
function handleLocationChange(event, data) {
callback({
url: data?.url ?? location.href,
type: data?.type ?? event.type,
});
}
$(window)
.on('locationchange', handleLocationChange) // pushState / replaceState
.on('popstate', handleLocationChange) // back / forward buttons
.on('hashchange', handleLocationChange); // hash-only changes (#anchor)
// Restore originals and detach listeners
function destroy() {
history.pushState = _push;
history.replaceState = _replace;
$(window)
.off('locationchange', handleLocationChange)
.off('popstate', handleLocationChange)
.off('hashchange', handleLocationChange);
}
return { destroy };
}
function isInView($el) {
const top = $el.offset().top;
const bottom = top + $el.outerHeight();
const winTop = $(window).scrollTop();
const winBottom = winTop + $(window).height();
return bottom > winTop && top < winBottom;
}
function JSONArrayStringParseToMap(JSONArrayString) {
if (!JSONArrayString) return new Map();
let parsed;
try { parsed = JSON.parse(JSONArrayString); }
catch (e) { return new Map(); }
if (!Array.isArray(parsed)) return new Map();
const entries = [];
for (const item of parsed) {
if (item == null) continue; // skip null/undefined
if (Array.isArray(item) && item.length >= 2) {
entries.push([item[0], item[1]]); // use first two elements as [k,v]
} else {
entries.push([item, ""]); // treat as single value -> [value, ""]
}
}
return new Map(entries);
}
function fetchUrl(url, onSuccess, onFailure) {
return new Promise(function (resolve, reject) {
console.log(`NHI - making an API request to ${url}...`);
$.get(url)
.done(function (data, textStatus, jqXHR) {
console.log(`NHI - API request to ${url} successful!`);
if (typeof onSuccess === 'function') onSuccess(data, textStatus, jqXHR);
resolve({ data: data, textStatus: textStatus, jqXHR: jqXHR });
})
.fail(function (jqXHR, textStatus, errorThrown) {
console.warn(`NHI - API request to ${url} failed!`, errorThrown);
if (typeof onFailure === 'function') onFailure(jqXHR, textStatus, errorThrown);
reject({ jqXHR: jqXHR, textStatus: textStatus, errorThrown: errorThrown });
});
});
}
function SaveToFile(filename, dataString) {
const blob = new Blob([dataString], { type: 'application/json' });
if (window.navigator.msSaveOrOpenBlob) {
window.navigator.msSaveBlob(blob, filename);
}
else {
const elem = window.document.createElement('a');
elem.href = window.URL.createObjectURL(blob);
elem.download = filename;
document.body.appendChild(elem);
elem.click();
document.body.removeChild(elem);
}
}
function CollectionsHaveSameContents(a, b) {
if (a === b) return true;
if (!a || !b) return false;
if (a.constructor !== b.constructor) return false;
if (a instanceof Set) {
if (a.size !== b.size) return false;
for (const v of a) if (!b.has(v)) return false;
return true;
}
if (a instanceof Map) {
if (a.size !== b.size) return false;
for (const [k, v] of a) {
if (!b.has(k)) return false;
if (b.get(k) !== v) return false;
}
return true;
}
throw new TypeError('Unsupported collection type');
}
function HandleImportedCollection(current, importedString, displayName, storageKey) {
if (!(current instanceof Set || current instanceof Map)) {
throw new TypeError('current must be a Set or Map');
}
// parse into imported collection
let imported;
if (current instanceof Set) {
let parsed;
try { parsed = JSON.parse(importedString); }
catch (e) { parsed = []; }
imported = new Set(Array.isArray(parsed) ? parsed.filter(x => x != null) : []);
} else { // Map
imported = JSONArrayStringParseToMap(importedString);
}
if (CollectionsHaveSameContents(current, imported)) return current;
const combineMessage =
`There were differences in the ${displayName} records between your current data and the imported file.\n` +
`Would you like to combine them?`;
if (confirm(combineMessage)) {
if (current instanceof Set) {
const combined = new Set([...current, ...imported]);
GM_setValue(storageKey, JSON.stringify([...combined]));
return combined;
} else {
// for Map, later entries from imported overwrite current
const combined = new Map([...current, ...imported]);
GM_setValue(storageKey, JSON.stringify([...combined])); // store as array of pairs
return combined;
}
}
const replaceMessage =
`Would you like to replace the current ${displayName} records with the data from the imported file?`;
if (confirm(replaceMessage)) {
if (current instanceof Set) {
GM_setValue(storageKey, JSON.stringify([...imported]));
return imported;
} else {
GM_setValue(storageKey, JSON.stringify([...imported]));
return imported;
}
}
return current; // user cancelled
}
function TryParseJSON(jsonString, defaultValue = null) {
try {
return JSON.parse(jsonString);
} catch {
return defaultValue;
}
}
function prettyNowISO() {
return new Date(Date.now()).toISOString(); // e.g. "2026-04-03T12:34:56.789Z"
}
function infoWithTimestamp(...args) {
const ts = new Date().toISOString();
console.info(`[${ts}]`, ...args);
}
function ReloadOnClickAfterTimeout($sel, timeout = 500, overrideExisting = true) {
const $elements = $sel && $sel.jquery ? $sel : $($sel);
if (!$elements.length) return;
if (overrideExisting) {
$elements.off('.softLoadWorkaround');
}
$elements.on('click.softLoadWorkaround', function (e) {
// only handle primary-button clicks without modifier keys
if (e.which !== 1 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
setTimeout(() => {
location.reload();
}, timeout);
});
}
// Returns true if Japanese (ja or ja-*) is present in the user's language preferences
// and appears before any English (en or en-*) entry.
function UserLangIsJapanese() {
const raw = Array.isArray(navigator.languages) && navigator.languages.length
? navigator.languages.slice()
: [navigator.language || navigator.userLanguage || ''];
const normalize = s => (s || '').toLowerCase();
let firstJaIndex = -1;
let firstEnIndex = -1;
for (let i = 0; i < raw.length; i++) {
const n = normalize(raw[i]);
if (firstJaIndex === -1 && (n === 'ja' || n.startsWith('ja-'))) firstJaIndex = i;
if (firstEnIndex === -1 && (n === 'en' || n.startsWith('en-'))) firstEnIndex = i;
if (firstJaIndex !== -1 && firstEnIndex !== -1) break;
}
// Japanese must exist and be earlier (smaller index) than English.
return firstJaIndex !== -1 && (firstEnIndex === -1 || firstJaIndex < firstEnIndex);
}
//#endregion