Licensed (in English)

Show if manga is licensed in English.

// ==UserScript==
// @name         Licensed (in English)
// @namespace    http://tampermonkey.net/
// @version      0.5
// @description  Show if manga is licensed in English.
// @author       Santeri Hetekivi
// @match        https://mangadex.org/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=mangadex.org
// @grant        GM.xmlHttpRequest
// @grant        window.onurlchange
// ==/UserScript==

(function() {
    'use strict';
    // ID for result element.
    const ID_RESULT = "licensedInEnglishResult"

    // Colors for different results.
    const COLOR_ERROR = "yellow"
    const COLOR_LICENSED = "red"
    const COLOR_UNLICENSED = "green"

    // Milliseconds to sleep between tries.
    const MS_SLEEP = 1000

    // URL starts.
    // Start for Manga Updates series url.
    const URL_START_MANGAUPDATES = "https://www.mangaupdates.com/series"
    // Start for MangaDex title url.
    const URL_START_MANGADEX = "https://mangadex.org/title/"

    // Querys for elements.
    // Parent to store result element in.
    const QUERY_PARENT = ".title"
    // Query for getting link for Manga Updates.
    const QUERY_LINK_MANGAUPDATES = "a[href^='"+URL_START_MANGAUPDATES+"']"
    // Query for getting elements that can contain TEXT_LICENSED_IN_ENGLISH text.
    const QUERY_MANGAUPDATES_LICENSED_IN_ENGLISH = ".sCat b"
    const QUERY_TRACK = ".font-bold.mb-2"

    // Texts.
    const TEXT_LICENSED_IN_ENGLISH = "Licensed (in English)"
    const TEXT_LICENSED_POSITIVE = "Yes"
    const TEXT_LICENSED_NEGATIVE = "No"
    const TEXT_TRACK = "Track"
    const TEXT_CONSOLE_START = "Licensed (in English):"

    // Log debug data.
    const logDebug = (...args) => {
        console.debug(TEXT_CONSOLE_START, ...args)
    }

    // Update result element.
    const updateLicensedInEnglishText = (_elementResult, _text, _color = "yellow") => {
        _elementResult.style.color = _color
        _elementResult.textContent = _text;
    }

    // Select node list with given _query.
    const selectNodeList = (_query, _document = document) => {
        const nodeList = _document.querySelectorAll(_query);
        if(!(nodeList instanceof NodeList))
        {
            throw "nodeList for query '"+_query+"' was not instance of NodeList!"
        }
        return nodeList
    }

    // Select single element.
    const selectElement = (_query) => {
        // Get node list.
        const nodeList = selectNodeList(_query);

        // Check that got only single result.
        const lengthNodeList = nodeList.length
        if(lengthNodeList !== 1)
        {
            throw "Found "+lengthNodeList+" nodes for '"+_query+"'!"
        }

        // Check that single element is instance of Element.
        const element = nodeList[0]
        if(!(element instanceof Element))
        {
            throw "Element for query "+_query+" was not instance of Element!"
        }

        // Return gotten element.
        return element
    }

    // Get element for result.
    const getElementResult = () => {
        return document.getElementById(ID_RESULT)
    }

    // Output given _error to console.
    const consoleError = (_error) => {
        console.error(TEXT_CONSOLE_START, _error)
    }

    // Handle given _error.
    const handleError = (_url, _error) => {
        // Output to console.
        consoleError(_error)

        // Get element for result.
        const elementResult = getElementResult()

        // If element result found.
        if(elementResult instanceof Element)
        {
            // Update error to result element.
            updateLicensedInEnglishText(
                elementResult,
                _error,
                COLOR_ERROR
            )
        }

        // If url given
        if(_url)
        {
            // remove url.
            urlLicensedStatus(_url, false, true, null)
        }
    }

    // Check that given _element is instance of Element.
    const checkIsElement = (_element, _name) => {
        if(!(_element instanceof Element))
        {
            throw _name+" was not instance of Element!"
        }
        return _element
    }


    // Get element with TEXT_LICENSED_IN_ENGLISH text.
    const getElementWithText = (
        _query,
        _text,
        _document = document
    ) => {
        // Loop node list.
        const nodeList = selectNodeList(_query, _document)
        let element = null
        for (let i = 0; i < nodeList.length; i++)
        {
            // Get element.
            element = checkIsElement(nodeList[i], "nodeList["+i+"]")
            // If text matches
            if (element.textContent == _text)
            {
                // return element.
                return element
            }
        }

        // No element found.
        return null
    }


    // Get element with TEXT_LICENSED_IN_ENGLISH text.
    const getElementLicensedInEnglish = (_document) => {
        // Get element for text.
        const element = getElementWithText(
            QUERY_MANGAUPDATES_LICENSED_IN_ENGLISH,
            TEXT_LICENSED_IN_ENGLISH,
            _document
        )
        // No element found.
        if (element === null)
        {
            throw "No element with text '"+TEXT_LICENSED_IN_ENGLISH+"' found!"
        }

        // Return element.
        return element
    }

    // Get text answer for licensed in english.
    const getTextLicensedInEnglish = (_document) => {
        // Get text.
        const text = checkIsElement(
            checkIsElement(
                getElementLicensedInEnglish(_document).parentElement ?? null,
                "getElementLicensedInEnglish.parentElement"
            ).nextElementSibling ?? null,
            "getElementLicensedInEnglish.parentElement.nextElementSibling"
        ).textContent.trim()

        // Check gotten text.
        if(
            text !== TEXT_LICENSED_POSITIVE
            &&
            text !== TEXT_LICENSED_NEGATIVE
        )
        {
            throw "Invalid text: "+text
        }

        // Return text.
        return text
    }

    // Handle response.
    const handleResponse = (_url, _response) => {
        // Log debug message.
        logDebug("Handling response...")
        // Get answer for licensed in english.
        logDebug("Getting licensedInEnglish text...")
        const licensedInEnglish = getTextLicensedInEnglish(
            (
                new DOMParser()
            ).parseFromString(
                _response.responseText,
                'text/html'
            )
        )

        // Get licensed.
        logDebug("Getting licensed from text "+licensedInEnglish+"...")
        let licensed = null
        if(licensedInEnglish === TEXT_LICENSED_NEGATIVE)
        {
            licensed = false
        }
        else if(licensedInEnglish === TEXT_LICENSED_POSITIVE)
        {
            licensed = true
        }
        else
        {
            throw "Invalid licensedInEnglish: "+licensedInEnglish
        }

        // Call on success.
        onSuccess(
             checkIsElement(
                getElementResult(),
                "getElementResult"
            ),
            _url,
            licensed
        )
    }

    // Handle success.
    const onSuccess = (_elementResult, _url, _licensed) => {
        // Write debug log.
        logDebug("onSuccess _url: "+_url+" _licensed: "+(_licensed ? "YES" : "NO"))
        // Update result element
        updateLicensedInEnglishText(
            _elementResult,
            (
                _licensed ?
                    TEXT_LICENSED_POSITIVE :
                    TEXT_LICENSED_NEGATIVE
            ),
            (
                _licensed ?
                    COLOR_LICENSED :
                    COLOR_UNLICENSED
            )
        )
        // Set curren url licensed status.
        urlLicensedStatus(_url, true, false, _licensed)
    }

    // Get url for Manga Updates.
    const getURLMangaUpdates = () => {
        // Init urls array
        const urls = []

        // Loop node list.
        const nodeList = selectNodeList(QUERY_LINK_MANGAUPDATES)
        for (let i = 0; i < nodeList.length; i++)
        {
            // Get href
            let href = checkIsElement(nodeList[i], "nodeList["+i+"]").href ?? null
            // If got href as string
            if (typeof href === "string")
            {
                // Trim href.
                href = href.trim()
                // If href not already in urls
                if(!urls.includes(href))
                {
                    // add href to urls.
                    urls.push(href)
                }
            }
        }

        // Check that has only one url.
        const lengthUrls = urls.length
        // If no urls found.
        if(lengthUrls === 0)
        {
            // throw error.
            throw "Manga Updates link not found!"
        }
        // If different amount than 1 links found
        if(lengthUrls !== 1)
        {
            // throw error.
            throw "Found "+lengthUrls+" Manga Updates urls: "+urls.join(",")
        }

        // Return url.
        return urls[0]
    }

    // Get current url.
    const currUrl = () => {
        const href = window.location.href ?? null
        if(
            typeof href !== "string"
            ||
            href.trim() === ""
        )
        {
            throw "Invalid url: "+href
        }
        return href
    }

    // Handle url licensed status.
    const urlLicensedStatus = (_url, _add = false, _remove = false, _licensed = null) => {
        // If _remove given
        if(_remove)
        {
            // and _add given
            if(_add)
            {
                // throw error.
                throw "Both _add and _remove given!"
            }

            // and _licensed given
            if(_licensed !== null)
            {
                // throw error.
                throw "Both _licensed and _remove given!"
            }
        }

        // If
        if(
            // is not MangaDex url
            !_url.startsWith(URL_START_MANGADEX)
            &&
            // and is not removing operation
            !_remove
        )
        {
            throw "Gotten _url was not MangaDex url: "+_url
        }


        // Init running urls.
        if(typeof urlLicensedStatus.data !== "object")
        {
            urlLicensedStatus.data = {}
        }

        // Was included already.
        const includedAlready = _url in urlLicensedStatus.data

        // Init included now to included already.
        let includedNow = includedAlready

        // If adding, not included and not wanted to remove
        if(_add && !includedNow && !_remove)
        {
            // add
            urlLicensedStatus.data[_url] = _licensed
            // and set included.
            includedNow = true
        }

        // If removing and included and does not have status
        if(_remove && includedNow && urlLicensedStatus.data[_url] === null)
        {
            // remove
            delete urlLicensedStatus.data[_url]
            // and update included now.
            includedNow = false
        }

        // If licensed given
        if(_licensed !== null)
        {
            // update status
            urlLicensedStatus.data[_url] = _licensed
            // and add to be included now.
            includedNow = true
        }

        // Debug log currently running urls.
        logDebug("urlLicensedStatus.data", urlLicensedStatus.data)

        // Return status.
        return (
            includedAlready ?
                urlLicensedStatus.data[_url] :
                undefined
        )
    }

    // Get element for track.
    const getElementTrack = () => {
        return getElementWithText(
            QUERY_TRACK,
            TEXT_TRACK
        )
    }

    // Return if needing to try again later.
    const tryAgain = (_licensedStatus) => {
        // If already running try again later.
        if(_licensedStatus === null)
        {
            return true;
        }

        // Get parent elements.
        const parentElementsLength = selectNodeList(QUERY_PARENT).length

        // If
        if(
            // no parent values found
            parentElementsLength === 0
        )
        {
            return true;
        }
        // Throw error if found other than 0 or 1 elements.
        else if(parentElementsLength !== 1)
        {
            throw "Found "+parentElementsLength+" nodes for '"+QUERY_PARENT+"'!"
        }

        // Return that track element is defined.
        return (
            getElementTrack() === null
        )
    }


    // Run logic.
    const run = (_url) => {
        // Log debug message.
        logDebug("Running "+_url+"...")

        // Get licensed status.
        const licensedStatus = urlLicensedStatus(_url, false, null)

        // Init data.
        if(typeof run.data !== "object")
        {
            run.data = {}
        }

        // Init data for given _url.
        if(!(_url in run.data))
        {
            run.data[_url] = {
                counter: 1,
                timeout: null
            }
        }

        // Debug data.
        logDebug("run.data", run.data)
        logDebug("Counter ", run.data[_url].counter)

        // If has active timeout
        if(run.data[_url].timeout !== null)
        {
            // throw error.
            throw "Already has active timeout!"
        }

        // Make sure that document is instance of HTMLDocument.
        if(!(document instanceof HTMLDocument))
        {
            throw "document was not instance of HTMLDocument!"
        }

        // If trying again.
        if(tryAgain(licensedStatus))
        {
            // Log debug message.
            logDebug("Trying again!")

            // Stop if counter is over 10.
            if(10 < run.data[_url].counter)
            {
                throw "Trying again, but counter is: "+run.data[_url].counter
            }

            // Call after timeout.
            run.data[_url].timeout = setTimeout(
                () => {
                    try
                    {
                        // Zero timeout.
                        delete run.data[_url].timeout
                        run.data[_url].timeout = null;
                        // Increment counter.
                        ++run.data[_url].counter
                        // Call run again.
                        run(_url)
                    }
                    catch(_error)
                    {
                        handleError(_url, _error)
                    }
                },
                MS_SLEEP
            );

            // Throw error.
            throw "Trying again in "+MS_SLEEP+" ms!"
        }

        // Get result element.
        let elementResult = getElementResult(ID_RESULT)

        // If no result element found.
        if(!(elementResult instanceof Element))
        {
            // Create result element.
            logDebug("Adding result element...")
            elementResult = document.createElement("p")
            elementResult.id = ID_RESULT
            selectElement(QUERY_PARENT).appendChild(elementResult);
            logDebug("Added result element.")
        }

        // If already has licensed status
        if(typeof licensedStatus === "boolean")
        {
            // just update element.
            onSuccess(
                elementResult,
                _url,
                licensedStatus
            )
            // and return.
            return
        }

        // Update result to loading.
        updateLicensedInEnglishText(
            elementResult,
            "Loading...",
            COLOR_ERROR
        )

        // Get Manga Updates page.
        logDebug("Making GET request...")
        GM.xmlHttpRequest(
            {
                method: "GET",
                url: getURLMangaUpdates(),
                headers: {
                    "Accept": "text/html"
                },
                onload: function(_response) {
                    // Handle response.
                    logDebug("onload")
                    try
                    {
                        handleResponse(_url, _response)
                    }
                    catch(_error)
                    {
                        handleError(_url, _error)
                    }
                },
                onerror: (_response) => {
                    logDebug("onerror")
                    consoleError(_response)
                    handleError(_url, _response.error ?? "Unknown error!")
                },
                ontimeout: (_response) => {
                    logDebug("ontimeout")
                    consoleError(_response)
                    handleError(_url, _response.error ?? "Unknown timeout!")
                },
                onabort: (_response) => {
                    logDebug("onabort")
                    consoleError(_response)
                    handleError(_url, _response.error ?? "Unknown timeout!")
                }
            }
        );
    }

    // After page has fully loaded.
    window.addEventListener(
        'load',
        function()
        {
            // Log debug message.
            logDebug("Page loaded!")
            // Init url.
            let url = null
            try
            {
                // Check that window.onurlchange feature is supported
                // https://www.tampermonkey.net/documentation.php#api:window.onurlchange
                if (window.onurlchange !== null)
                {
                    throw "window.onurlchange feature is not supported!"
                }

                // Add url change event.
                window.addEventListener(
                    'urlchange',
                    (_info) => {
                        // Handle urlchange.
                        logDebug("urlchange")
                        const url = _info.url
                        try
                        {
                            run(url)
                        }
                        catch(_error)
                        {
                            handleError(url, _error)
                        }
                    }
                );

                // Start running.
                logDebug("Start running!")
                url = currUrl();
                run(url)
            }
            catch(_error)
            {
                handleError(url, _error)
            }
        },
        false
    );
})();