WaniKani hide mnemonics

Adds a possiblity to hide meaning and reading mnemonics.

// ==UserScript==
// @name WaniKani hide mnemonics
// @namespace wkhidem
// @description Adds a possiblity to hide meaning and reading mnemonics.
// @version 1.8
// @author Niklas Barsk
// @include https://www.wanikani.com/review/session*
// @include https://www.wanikani.com/lesson/session*
// @include https://www.wanikani.com/radicals/*
// @include https://www.wanikani.com/kanji/*
// @include https://www.wanikani.com/vocabulary/*
// @run-at document-end
// ==/UserScript==

/*
 * This script is licensed under the MIT licence.
 */

if (isReview() || isLesson())
{
    // review/lessons quiz
    var mo = new MutationObserver(initQuiz);
    mo.observe(document.getElementById("item-info-col2"), {'childList': true});
}

if (isLesson())
{
    // Call init whenever the main-info class attribute is updated.
    // This happens whenever the user switch to a new item on the
    // lesson/learning part.
    var mo = new MutationObserver(init);
    mo.observe(document.getElementById("main-info"), {'attributes': true});
}

if (isLookup())
{
    if (document.getElementById("progress").style.display != "none")
    {
        // only run the script on items that has been unlocked since it's
        // not possible to add user mnemonics on locked items.
        init();
    }
}

function initQuiz(allmutations)
{
    if (allmutations[0].addedNodes.length > 0)
    {
        // Ignore the mutation if no nodes are added.
        // When going from one question to the next all elements in
        // item-info-col2 is first removed as one mutation and then
        // the new content is added as a second mutation. So mutations
        // without any added nodes should be ignored
        init();
    }
}

function init()
{
    if (!sanityCheckPassed())
    {
        // Don't try to run the script if the HTML can't be parsed.
        console.warn("WaniKani hide mnemonics need to be updated to support the latest version of WaniKani.");
        return;
    }

    setCorrectText();
    setCorrectVisibility();

    if (isQuiz())
    {
        // Update visibility state when the "Show All Information" button is pressed.
        document.getElementById("all-info").addEventListener("click", setCorrectVisibility);
    }

    // Setup listeners for changes to the note-meaning/reading.
    var mo = new MutationObserver(onNoteChanged);
    var options = {'childList': true};
    mo.observe(getNoteElement("meaning"), options);

    if (!isRadical())
    {
        mo.observe(getNoteElement("reading"), options);
    }
}


/**
 * Called whenever the note-reading or note-meaning elements children
 * are updated.
 */
function onNoteChanged(allmutations)
{
    // 1 children for the edit note form and 0 children when the
    // note is being displayed. Update visibility when form is closed
    // and note is shown again.
    if (allmutations[0].target.children.length == 0)
    {
        // example id for quiz: 'meaning-note'
        // example id for learning: 'supplement-voc-meaning-notes'
        var index = isLesson() && !isQuiz() ? 2 : 1;
        var which = allmutations[0].target.parentNode.id.split('-')[index];
        setCorrectVisibilityFor(which);
    }
}

/**
 * Set the correct text for the meaning and reading headers depending on
 * the current state.
 */
function setCorrectText()
{
    setCorrectTextFor("meaning");
    if (!isRadical())
    {
        setCorrectTextFor("reading");
    }
}

/**
 * Returns true if the mnemonic is hidden for the current character
 * and the given type.
 */
function isHidden(which)
{
    return isManuallyHidden(which) || isAutomaticallyHidden(which);
}

/**
 * Returns true if the user has hidden the mnemonic with the hide link.
 */
function isManuallyHidden(which)
{
    return localStorage.getItem(getStorageKey(which)) == "0";
}

/**
 * Returns true if the mnemonic has been hidden because there
 * is a note present.
 */
function isAutomaticallyHidden(which)
{
    return hasNote(which) &&
           localStorage.getItem(getStorageKey(which)) != "1";
}

/**
 * Set hidden status for the current character in the localStorage
 * for the give type.
 * @param which "reading" or "meaning" depending on which key is desired.
 * @param what new value for the current character:
 *             0: user has manually hidden the mnemonic.
 *             1: user has shown an automatically hidden mnemonic.
 */
function setStorage(which, what)
{
    localStorage.setItem(getStorageKey(which), what);
}

/**
 * Remove the stored information about the current character from
 * the localStorage for the give type.
 * @param which "reading" or "meaning" depending on which key is desired.
 */
function clearStorage(which)
{
    localStorage.removeItem(getStorageKey(which));
}

/**
 * Get the key that the removed state for the current character is
 * stored under in the localStorage.
 * @param "reading" or "meaning" depending on which key is desired.
 */
function getStorageKey(which)
{
    return getCharacterType() + "_" + getCharacter() + "_" + which;
}

/**
 * Return a textual representation of the current character.
 * For vocabulary, kanji and normal radicals it is the vocabulary,
 * kanji, or radical itself. For radicals that are just an image
 * it is the file name of the image.
 */
function getCharacter()
{
    var element;
    if (isLookup())
    {
        element = document.getElementsByClassName("japanese-font-styling-correction")[0];
    }
    else
    {
        element = document.getElementById("character");
    }

    var character = element.textContent.trim();
    if (character == "") // Radical with image instead of text.
    {
        var img = element.children[0];
        // During quiz the image is inside a span, during lessons
        // the image is directly under the character div.
        if (isQuiz())
        {
            img = img.children[0]
        }
        character = img.getAttribute("src").split("/").pop()
    }
    return character
}

/**
 * Return the type of the character the page is for: a string containing
 * "vocabulary", "kanji" or "radical".
 */
function getCharacterType()
{
    if (isLesson())
    {
        return document.getElementById("main-info").className.trim();
    }
    else if (isReview())
    {
        return document.getElementById("character").className.trim();
    }
    else if (isLookup())
    {
        var character = document.getElementsByClassName("japanese-font-styling-correction")[0];
        var cn = character.parentElement.className;
        return cn.substr(0, cn.indexOf("-"));
    }
}

/**
 * Return true if the current page is for a radical.
 */
function isRadical()
{
    return getCharacterType() == "radical";
}

/**
 * Return true if the current page is for vocabulary.
 */
function isVocabulary()
{
    return getCharacterType() == "vocabulary";
}

function isLookup()
{
    return document.URL.indexOf("/radicals/") != -1 ||
           document.URL.indexOf("/kanji/") != -1 ||
           document.URL.indexOf("/vocabulary/") != -1;
}

/**
 * Returns true if the current page is a lesson.
 */
function isLesson()
{
    return document.URL.indexOf("lesson") != -1;
}

/**
 * Returns true if the current page is a review.
 */
function isReview()
{
    return document.URL.indexOf("review") != -1;
}

/**
 * Returns true if the user is currently doing a quiz.
 */
function isQuiz()
{
    if (isReview())
    {
        return true;
    }
    if (isLesson())
    {
        var mainInfo = document.getElementById("main-info");
        return mainInfo.parentElement.className == "quiz";
    }
    return false;
}

/**
 * Returns true if the current item has a note set.
 * @param which specifies if it's the reading or meaning
 *              note that is of interest.
 */
function hasNote(which)
{
    return getNoteElement(which).textContent.trim() != "Click to add note";
}

/**
 * Set the correct visibility of the reading and meaning sections.
 */
function setCorrectVisibility()
{
    setCorrectVisibilityFor("meaning");
    if (!isRadical())
    {
        setCorrectVisibilityFor("reading");
    }
}

/**
 * Set the correct visibility for the specified header depending on the current state.
 * @param which The header that should be updated, either "reading" or "meaning".
 */
function setCorrectVisibilityFor(which)
{
    if (hiddenByWaniKani(which))
    {
        // Don't touch visibility for things hidden by WaniKani.
        return;
    }

    if (isHidden(which)) // In this case, should be hidden
    {
        hide(which);
    }
    else
    {
        show(which);
    }
}

/**
 * When doing a quiz WaniKani only shows the info that was being asked for
 * to see all info the user need to press a button to display it.
 * This method returns true if the given reading/meaning is currently hidden.
 *
 * @param which "reading" or "meaning"
 */
function hiddenByWaniKani(which)
{
    if (!isQuiz())
    {
        return false;
    }
    var infoHidden = document.getElementById("all-info").style.display != "none";
    var questionType = document.getElementById("question-type").className;
    return which != questionType && infoHidden;
}

/**
 * Set the correct state in local storage for the current item
 * and mnenemonic based on the given action.
 * @param action "hide" or "show"
 * @param which "reading" or "meaning"
 */
 function setCorrectStorage(action, which)
{
    var note = hasNote(which);
    if (action == "show" && !note ||
        action == "hide" && note)
    {
        // Default state, cleare any storage
        clearStorage(which);
    }
    else if (action == "show" && note)
    {
        // Force section to be visible
        setStorage(which, 1);
    }
    else if (action == "hide" && !note)
    {
        // Force section to be hidden
        setStorage(which, 0);
    }
}

/**
 * Hide the specified section.
 * @param which The section that should be hidden, either "reading" or "meaning".
 */
function hide(which)
{
    setCorrectStorage("hide", which);
    setDisplayStyle(which, "none");
    setCorrectText();
}

/**
 * Show the specified section.
 * @param which The section that should be shown, either "reading" or "meaning".
 */
function show(which)
{
    setCorrectStorage("show", which);
    setDisplayStyle(which, "");
    setCorrectText();
}

/**
 * Set the display style of the hidable section.
 * @param which The section that should be updated, either "reading" or "meaning".
 * @param display The new value of the display css property.
 */
function setDisplayStyle(which, display)
{
    var children = getHidableElements(which);
    for (i = 0; i < children.length; ++i)
    {
        children[i].style.display = display;
    }
}

/**
 * Returns an array with all elements that should be hidden or
 * shown when the hide/show link is clicked.
 * @param Specifies if it's the "reading" or "meaning" that should be hidden
 */
function getHidableElements(which)
{
    // return an array of items to hide/show
    var ret = [];
    if (isQuiz())
    {
        ret.push(getMnemonicContainer(which));
    }
    else if (isLesson())
    {
        var children = getLearningContainer(which).children;
        for (i = 0; i < children.length - 2; ++i) // note section is last 2 elements.
        {
            ret.push(children[i]);
        }
    }
    else if (isLookup())
    {
        if (isRadical())
        {
            ret.push(getLookupMnemonicContainer(which));
        }
        else
        {
            var children = getLookupMnemonicContainer(which).children;
            for (i = 0; i < children.length - 1; ++i) // note section is the last element.
            {
                ret.push(children[i]);
            }
        }
    }
    return ret;
}

/**
 * Set the correct text for reading/meaning/note header with the apropriate
 * show/hide link depending on what the current state is.
 *
 * @param which Specifies which header should be updated, the "reading" or "meaning" header.
 * @param action Specifies what happens when the header is pressed, either "show" or "hide".
 * @param headerID The ID of the header that should be updated.
 * @param header The DOM element which should have its text updated.
 */
function textForHeader(which, action, header)
{
    // Add the show/hide link to the header.
    header.innerHTML = header.firstChild.textContent + getLinkHTML(which, action);

    // Set either hide(which) or show(which) as onclick handler for the new link.
    document.getElementById(getLinkId(which, action)).onclick = function() 
    {
        if (action == "show")
        {
            show(which);
        }
        else
        {
            hide(which);
        }
    };
}

/**
 * Get the HTML for the show/hide link.
 * @param which Specifies if the link is for "reading" or "meaning".
 * @param action Specifies if the link is "hide" or "show".
 */
function getLinkHTML(which, action)
{
    // Examples of what the html looks like:
    // <span id="show-reading">(show original meaning)</span>
    // <span id="hide-meaning">(hide)</span>

    var linkText = action;
    if (action == "show")
    {
        if (isVocabulary())
        {
            linkText += " original explanation";
        }
        else
        {
            linkText += " original mnemonic";
        }
    }

    return "<span id=\"" + getLinkId(which, action) + "\"> (" + linkText + ")</span>";
}

/**
 * Return the id of the show/hide link.
 */
function getLinkId(which, action)
{
    var quiz = isQuiz() ? "-q" : "";
    return action + "-" + which + "-" + getCharacterType() + quiz;
}

/**
 * Set the correct text for the specified header depending on the current state.
 * @param which The header that should be updated, either "reading" or "meaning".
 */
function setCorrectTextFor(which)
{
    if (isHidden(which))
    {
        // Display the "show" link in the note.
        textForHeader(which, "show", getNoteHeader(which));
    }
    else
    {
        // Display the "hide" link in the header.
        textForHeader(which, "hide", getMnemonicHeader(which));

        // Make sure the default version of the Note header is displayed.
        var nh = getNoteHeader(which);
        nh.innerHTML = nh.firstChild.textContent;
    }
}

/**
 * Get the DOM element that contains the mnemonic.
 * @param which Specifies if the header for the reading or meaning should
 *              be returned. The parameter is ignored for radicals since
 *              they only have one mnemonic.
 */
function getMnemonicContainer(which)
{
    if (isRadical())
    {
        return document.getElementById("item-info-col2").children[0];
    }
    else
    {
        return document.getElementById("item-info-" + which + "-mnemonic");
    }
}

/**
 * Return the element that contains the mnemonics for the lookup pages.
 */
function getLookupMnemonicContainer(which)
{
    if (isRadical())
    {
        return document.getElementById("note-" + which).previousElementSibling;
    }
    else
    {
        return document.getElementById("note-" + which).parentElement;
    }
}

/**
 * Get the DOM element for the mnemonic header.
 * @param which Specifies if the header for the reading or meaning should
 *              be returned. The parameter is ignored for radicals since
 *              they only have one mnemonic.
 */
function getMnemonicHeader(which)
{
    if (isQuiz())
    {
        return getMnemonicContainer(which).children[0];
    }
    else if (isLesson())
    {
        return getLearningContainer(which).children[0];
    }
    else if(isLookup())
    {
        return getLookupMnemonicContainer(which).children[0];
    }
}

/**
 * Get the DOM element for the user notes header.
 * @param which Specifies if the notes header for the reading or meaning
 *              should be returned.
 */
function getNoteHeader(which)
{
    if (isQuiz() || isLookup())
    {
        return document.getElementById("note-" + which).children[0];
    }
    else if (isLesson())
    {
        var container = getLearningContainer(which);
        return container.children[container.children.length - 2];
    }
}

/**
 * Returns the DOM element that holds the user note.
* @param which Specifies if the notes element for the reading or meaning
 *              should be returned.
 */
function getNoteElement(which)
{
    var element;
    if (isLesson() && !isQuiz())
    {
        var id;
        if (isRadical())
        {
            id = "supplement-rad-name-notes";
        }
        else
        {
            id = "supplement-" + getCharacterType().substring(0,3) + "-" + which + "-notes";
        }
        element = document.getElementById(id).children[0];
    }
    else
    {
        element = document.getElementById("note-" + which).children[1];
    }
    return element;
}

/**
 * Get the container element of the mnemonics and notes in the learning
 * part of lessons. There is no id available for the actual headers and
 * mnemonics like in the quiz so the container element is the closest
 * we get.
 *
 * @param which Specifies if it's the container for "reading" or "meaning"
 *              that is desired.
 */
function getLearningContainer(which)
{
    var id = "supplement-" + getCharacterType().substring(0,3) + "-";
    var className = "pure-u-3-4";
    if (isRadical())
    {
        id += "name";
        className = "pure-u-1"
    }
    else
    {
        id += which;
    }

    return document.getElementById(id).getElementsByClassName(className)[0];
}

/**
 * Return true if critical assumptions made about the HTML code holds.
 */
function sanityCheckPassed()
{
    try
    {
        if (isLookup())
        {
            sanityCheckLookup();
        }

        if (isQuiz())
        {
            sanityCheckQuiz();
        }

        if (isLesson())
        {
            sanityCheckLesson();
        }

        // Make sure we can get a correct character type.
        var ct = getCharacterType();
        if (ct != "radical" && ct != "vocabulary" && ct != "kanji")
        {
            throw new Error("Unknown character type: " + ct);
        }

        // Make sure we can get a correct storage key
        var parts = getStorageKey("meaning").split("_");
        if (parts.length != 3 || parts[0] == "" ||
            parts[1] == ""    || parts[2] == "")
        {
            throw new Error("Unable to generate a correct storage key: " + key);
        }
    }
    catch (e)
    {
        console.error(e.toString());
        return false;
    }
    return true;
}

/**
 * Throws an exception if the critical assumptions made about the
 * HTML code in the lookup related code are wrong.
 */
function sanityCheckLookup()
{
    if (document.getElementsByClassName("japanese-font-styling-correction").length == 0)
    {
        throw new Error("No element with class 'japanese-font-styling-correction' exists");
    }

    ensureElementExists("note-meaning");
    if (!isRadical())
    {
        ensureElementExists("note-reading");
    }
}

/**
 * Throws an exception if the critical assumptions made about the
 * HTML code in the quiz related code are wrong.
 */
function sanityCheckQuiz()
{
    ensureElementExists("character");
    ensureElementExists("all-info");
    ensureElementExists("item-info-col2");
    ensureElementExists("note-meaning");
    var questionType = ensureElementExists("question-type");
    questionType = questionType.className;

    if (questionType != "reading" && questionType != "meaning")
    {
        throw new Error("'question-type' is neither \"reading\" nor \"meaning\", it is \"" + questionType + "\"");
    }

    if (!isRadical())
    {
        ensureElementExists("item-info-reading-mnemonic");
        ensureElementExists("item-info-meaning-mnemonic");
        ensureElementExists("note-reading");
    }
}

/**
 * Throws an exception if the critical assumptions made about the
 * HTML code in the lesson related code are wrong.
 */
function sanityCheckLesson()
{
    ensureElementExists("character");
    var mainInfo = ensureElementExists("main-info");

    // Make sure assumptions for lessons in isQuiz() holds.
    var cn = mainInfo.parentElement.className;
    if (cn != "" && cn != "quiz")
    {
        throw new Error("Parent of 'main-info' is neither empty nor \"quiz\"");
    }

    if (!isQuiz())
    {
        ensureElementExists("supplement-rad-name-notes");
        ensureElementExists("supplement-kan-meaning-notes");
        ensureElementExists("supplement-voc-meaning-notes");
        ensureElementExists("supplement-kan-reading-notes");
        ensureElementExists("supplement-voc-reading-notes");

        ensureElementExistsAndHasClass("supplement-voc-reading", "pure-u-3-4");
        ensureElementExistsAndHasClass("supplement-voc-meaning", "pure-u-3-4");
        ensureElementExistsAndHasClass("supplement-kan-reading", "pure-u-3-4");
        ensureElementExistsAndHasClass("supplement-kan-meaning", "pure-u-3-4");
        ensureElementExistsAndHasClass("supplement-rad-name", "pure-u-1");
    }
}

/**
 * Throws an exception if the given id doesn't exist in the DOM tree.
 * @return the element if it exist
 */
function ensureElementExists(id)
{
    var element = document.getElementById(id);
    if (element == null)
    {
        throw new Error(id + " does not exist");
    }
    return element;
}

/**
 * Throws an exception if the given id doesn't exist in the DOM tree.
 */
function ensureElementExistsAndHasClass(id, className)
{
    var element = ensureElementExists(id);
    if (element.getElementsByClassName(className)[0] == null)
    {
        throw new Error(id + " does not contain any element with class: " + className);
    }
}