NHentai Improved

Partially fade/remove non-english, HQ thumbnails, mark as read, subs, version grouping etc.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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