[Japanese] WaniKanify Vocabularizer

Replaces vocabulary on English Websites with Japanese vocabulary learned from WaniKani

// ==UserScript==
// @name		[Japanese] WaniKanify Vocabularizer
// @namespace		wanikanify_kaiko
// @description		Replaces vocabulary on English Websites with Japanese vocabulary learned from WaniKani
// @include		*
// @exclude	/^https?://.*wanikani\.com/?/
// @exclude	/^http?://.*wanikani\.com/?/
// @version     1.0.0
// @grant		 GM_registerMenuCommand
// @grant       GM_xmlhttpRequest
// @grant       GM_getValue
// @grant       GM_setValue
// @grant		GM_deleteValue
// @require		http://ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js
// ==/UserScript==

/* ---------------------------------------------------------------------------------------------------------------------------------------------
******************************************** TO NEW USERS: *****************************************************
------------------------------------------------------------------------------------------------------------------------------------------------
BACK UP THIS SCRIPT AFTER YOU EDIT IT!
- This script may be replaced by GreaseMonkey at any time, especially if the script Author updates this script and it redownloads the latest version.
- If this script is replaced, then all you need to do is re-edit the new script and copy+paste over your edited user lists from your backup.

HOW TO EDIT THE SCRIPT
- There is a tutorial at the GreasyFork page: https://greasyfork.org/scripts/5164-wanikanify-vocabularizer

@ "var ignoredWaniKaniMeanings":
- Words added here will be ignored from WaniKani. Make sure to type the meanings exactly as they are on WaniKany, one entry at a time.

@ "var userWords"
- Words added here will override ignoredWaniKaniMeanings if the same meaning exists in both.
- Words added here will not be automatically conjugated (not even plurals), and any translation errors are your own doing. 
- Will override WaniKani meanings. Example the entry "mountains" will override "mountain" from WaniKani conjugating to "mountains", but won't stop other conjugations (eg: ed, ing).

SYNTAX MATTERS!
- Follow the structures of the current example lists. If the script breaks after you edit these lists, then check your syntax!

REMOVING THE RIGHT-CLICK 'CONTEXT MENU'
@ "var contextMenu" is enabled if "true", or disabled if "false".
The menu is always accessible from GreaseMonkey's add-on menu.

Note: I've tried to make this as user accessible as possible. Sorry for any issues, however I am working within limitations of GreaseMonkey.
------------------------------------------------------------------------------------------------------------------------------------------------
******************************************** TO NEW USERS: *****************************************************
----------------------------------------------------------------------------------------------------------------------------------------------*/

var ignoredWaniKaniMeanings = [
    "the answer",
	"pm",
	"hide",
	"hello",
]

var userWords = [
	{character: "山々", kana: "やまやま", meaning: "mountains"},
	{character: "京", kana: "きょう", meaning: "capital"},
	{character: "カイ", kana: "カイ", meaning: "kai"},
]


// true to enable right-click context menu, false to disable it | Always accessible from GreaseMonkey's add-on menu
var contextMenu = true; 

//----------------------------------------------------------
//INITIALIZE MENU CREATION/INTERACTION - Only firefox compatible
//----------------------------------------------------------
var scriptName = "WaniKanify: ";
// If we're downloading new vocabulary; stop from redownloading while already downloading
var downloading = false;
// Run the script after updating our vocabulary
var runAfter = false;
// Get our API key
var apiKey = GM_getValue("apiKey");

if (contextMenu) {
    var wanikani_menu = document.body.appendChild(document.createElement("menu"));
    wanikani_menu.outerHTML = '<menu id="wanikanify-menu" type="context">\
		<menu label="WaniKanify">\
			<menuitem label="Run WaniKanify" id="wanikanify-run" icon="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsSAAALEgHS3X78AAACmUlEQVQ4y4WSW0iUURSFv3P+X2e8NWjNmFqjRBcrgiwCIaGIKCjoocIQJKy3Igqieil76EbQDYISjEAz0u5FhPQQGmUTRaVSmGkDM2EXB2/j6Nz+mdPDP+UlqQ3nZZ291tl7nSW4pZiiPIBzEuYF8scDUsSRk5p6ATUFmQSmEj0AOH52ThBQgJ3/lx1Q987M4Ydj4R8BU1VATnUxBVWF/DVbonKaDjFYt4RARkqvkKD/Vp19ZQVlFV8Z2uonHIyR6reiYlBlCwGQX7OGjVtc6MU659KyEacCdiliSAQeBGyp6GA4OIjU42RMF6i4OdEuv5VNbyxs2PzSBESMyrll2DSNYHe5R5Yc1J17R22EgwZfPoYBMCIJlzWwJqWycJmd57VxbL37OL/0IAcuPma6oRF/1eUUZYvtKnN/AKXiXC4+TNiiYUnWoT8AKRbT3VCEUMSgsbmLFw1NFJFKaV4mWK3IIaeFTpdhPhmNkjwcNMkAoQi3H7aS1rabtded+GpbOJ6ba5KBuu4fiO8th9TVuy3sLV9NhjCtP1HTTId/kBsb2s3PtcJIdRGaUDzp9bPeMY36ngF2VHrQz2665N25fZXz/umnACTpGiVpyfgeugkPLEfFYtzqGUBzzoCYgWc4SN4uF32p2RDFK97tWUdhtk01tHnRpDB3/txDYDRIli2dJAEhI07Xtz6OVQUgOiEWQi/MtnGz3evTpLA70q343ruRUpCTZePOiJ36StdY+0SyDxJ5k0I4+gdH8LW6cfcNE1NxHr3+wqKg+1+RdgDIm+1eAB50CXFpQYVvljCIGor582byQdlpvrYSlBp/fGaiEjtQOzR2CRw5WsDoSMTTuO2Cc9anZ7wtPUl/ShYI4UXIfBAgJQgBQvILiA4DtrlIXJsAAAAASUVORK5CYII="></menuitem>\
			<menuitem label="Refresh vocabulary" id="wanikanify-refresh"></menuitem>\
			<menuitem label="Toggle auto-run" id="wanikanify-autorun"></menuitem>\
			<menuitem label="Set API key" id="wanikanify-apikey"></menuitem>\
		</menu>\
	</menu>';

    document.body.setAttribute("contextmenu", "wanikanify-menu");
    document.querySelector("#wanikanify-run").addEventListener("click", function () { run(); }, false);
    document.querySelector("#wanikanify-refresh").addEventListener("click", function () { tryRefreshVocab(); }, false);
    document.querySelector("#wanikanify-autorun").addEventListener("click", function () { checkAutoRun(); }, false);
    document.querySelector("#wanikanify-apikey").addEventListener("click", function () { promptApiKey(); }, false);
}

GM_registerMenuCommand(scriptName + "Run", run);
GM_registerMenuCommand(scriptName + "Refresh vocabulary", tryRefreshVocab);
GM_registerMenuCommand(scriptName + "Toggle auto-run", checkAutoRun);
GM_registerMenuCommand(scriptName + "Set API key", promptApiKey);

function tryRefreshVocab() {
    if (apiKey != undefined) downloadVocab();
    else if (apiKey == undefined) promptApiKey();
}

function checkAutoRun() {
    var autoRun = GM_getValue("autoRun");
    if (apiKey == undefined) {
        promptApiKey();
    } else if (autoRun != true) {
        autoRun = true;
        GM_setValue("autoRun", autoRun);
        run();
    } else {
        autoRun = false;
        GM_setValue("autoRun", autoRun);
    }
}

// AutoRun if we can
window.addEventListener ("load", function () {
	if (apiKey != undefined && GM_getValue("autoRun") == true) {
		run();
	}
}, false);

function promptApiKey() {
    while (true) {
        apiKey = GM_getValue("apiKey");
        apiKey = window.prompt("Please enter your API key", apiKey ? apiKey : "");
        if (apiKey !== null && !/^[a-fA-F0-9]{32}$/.test(apiKey)) alert("That was not a valid API key, please try again");
        else break;
    }

    if (apiKey != undefined && apiKey != GM_getValue("apiKey")) GM_setValue("apiKey", apiKey);
}

//----------------------------------------------------------
//END MENU CREATION/INTERACTION
//----------------------------------------------------------
function run() {
	if (apiKey != undefined){
		// If we haven't ever downloaded our vocabulary, then lets do so before we run
		if (GM_getValue("wanikaniVocabLookup") != undefined && !downloading) {
			replaceVocab();
		} else if (!downloading) {
			runAfter = true;
			downloadVocab();
		} else if (downloading) {
			runAfter = true;
		}
	}
	else {
		promptApiKey();
	}
}

//Massive credit to http://stackoverflow.com/a/9229821
function uniqueArray(a, key) {
    var seen = {};
    return a.filter(function (item) {
        var k = key(item);
        return seen.hasOwnProperty(k) ? false : (seen[k] = true);
    })
}

function downloadVocab() {
    downloading = true;

    GM_xmlhttpRequest({
        method: "GET",
        url: "http://www.wanikani.com/api/v1.2/user/" + apiKey + "/vocabulary/",
        onerror: function (response) {
            alert(scriptName + "Error while downloading new vocab. Please try again later.");
            downloading = false;
        },
        onload: function (response) {
            var json;

            try {
                json = JSON.parse(response.responseText);
            } catch (e) {
                alert(scriptName + "Unable to process new vocab. Please try again later.", e);
            }

            if (json) {
                // Loop through all received words
                var vocabList = json.requested_information.general;
                var wanikaniVocab = [];

                for (var i = 0; i < vocabList.length; i++) {
                    var vocab = vocabList[i];
                    if (!vocab.user_specific) continue; // Skip words not yet learned

                    // Split multiple meanings from WaniKani
                    var meanings = vocab.meaning.split(", ");

                    // Remove words we don't want from WaniKani, and force our user words to override WaniKani words
                    for (var m = 0; m < meanings.length; m++) {
						for (var x = 0; x < ignoredWaniKaniMeanings.length; x++) {
                            if (meanings[m] === ignoredWaniKaniMeanings[x]) {
                                meanings.splice(m, 1);
                            }
                        }
						
						for (var y = 0; y < userWords.length; y++) {
                            if (meanings[m] === userWords[y].meaning) {
                                meanings.splice(m, 1);
                            }
                        }
                    }

                    // Conjugate verbs
                    var conjugations = [];
                    for (var m = 0; m < meanings.length; m++) {
                        var word = meanings[m];
                        // If a verb...
                        if (/^to /.test(word)) {
                            // Remove leading 'to'
                            meanings[m] = word.substr(3);
							word = meanings[m];
							
                            // Remove 'e' suffix for conjugations
                            if (word.slice(-1) == "e") {
								word = word.slice(0, -1);
							}

                            if (!/ /.test(word)) {
								conjugations.push(word + "ed", word + "es", word + "en", word + "es", word + "s", word + "ing");
							}
                        }

                        // Not a verb, try plural
                        else if (word.length >= 3 && word.slice(-1) != "s") {
							conjugations.push(word + "s");
						}
                    }

					//Remove Wanikani words we don't want...
					for (var c = 0; c < conjugations.length; c++){
                        for (var y = 0; y < userWords.length; y++) {
                            if (conjugations[c] === userWords[y].meaning) {
                                conjugations.splice(c, 1);
                            }
                        }
						
						for (var e = 0; e < ignoredWaniKaniMeanings.length; e++) {
                            if (conjugations[c] === ignoredWaniKaniMeanings[e]) {
                                conjugations.splice(c, 1);
                            }
                        }
                    }
					
                    meanings.push.apply(meanings, conjugations);

                    // Finalize our vocabulary
					for (var m = 0; m < meanings.length; m++) {
                        //Hopefully eval() here is safe...
                        var wanikaniVocabStr = '{"character":vocab.character, "kana":vocab.kana, "meaning":meanings[m] }';
                        var wanikaniVocabStrObj = eval("(" + wanikaniVocabStr + ")");
                        wanikaniVocab.push(wanikaniVocabStrObj);
                    }
                    // Add our custom non-conjugated words
					wanikaniVocab.push.apply(wanikaniVocab, userWords);
                }

                // Eliminate duplicate results
                var wanikaniVocabUnique = uniqueArray(wanikaniVocab, JSON.stringify);

                // Remap our array of objects to an object; drastically faster access than a for-loop at run-time for text replacement
                var wanikaniVocabLookup = {};
                for (var i = 0; i < wanikaniVocabUnique.length; i++) {
                    wanikaniVocabLookup[wanikaniVocabUnique[i].meaning] = wanikaniVocabUnique[i];
                }
				
                // Update our local SQLite database storage with the new vocabulary
                var vocabJSON = JSON.stringify(wanikaniVocabLookup);

                GM_deleteValue("wanikaniVocabLookup");
                GM_setValue("wanikaniVocabLookup", vocabJSON);

                if (!runAfter) alert(scriptName + "Successfully updated vocab!");
                else alert(scriptName + "Successfully updated vocab! Now running...");

                // Release download lock
                downloading = false;

                // if we had to redownload vocab before running, continue to run
                if (runAfter) replaceVocab(wanikaniVocabLookup);
            }
        }
    });
}

function replaceVocab(wanikaniVocabLookup) {
    // No vocab array given, try to parse locally with JSON
    if (!wanikaniVocabLookup || wanikaniVocabLookup == undefined) {
        try {
            wanikaniVocabLookup = JSON.parse(GM_getValue("wanikaniVocabLookup"));
            if (!wanikaniVocabLookup || (wanikaniVocabLookup && jQuery.isEmptyObject(wanikaniVocabLookup))) throw 1;
        } catch (e) {
            alert(scriptName + "Error while parsing the vocab list; deleting it now. Please refresh vocabulary.");
            GM_deleteValue("wanikaniVocabLookup");
            return;
        }
    }

    var replaceCallback = function (str) {
        // Performance of this may be questionable (in the future)... please don't lynch me programmer gods ;-;
        if (wanikaniVocabLookup.hasOwnProperty(str.toLowerCase())) {;
            var translation = wanikaniVocabLookup[str.toLowerCase()];
            return '<span style="font-family:arial" class="wanikanified" title="' + str + " [" + translation.kana + "] " + '" data-en="' + str + '" data-jp="' + translation.character +
                '" onClick="var t = this.getAttribute(\'data-en\'); this.setAttribute(\'data-en\', this.innerHTML); this.innerHTML = t;">' + translation.character + '</span>';
        }
        return str;
    };

    var nodes = $("body *:not(noscript):not(script):not(style)");

    // Very naive attempt at replacing vocab consisting of multiple words first
    nodes.replaceText(/\b(\S+?\s+\S+?\s+\S+?\s+\S+?)\b/g, replaceCallback);
    nodes.replaceText(/\b(\S+?\s+\S+?\s+\S+?)\b/g, replaceCallback);
    nodes.replaceText(/\b(\S+?\s+\S+?)\b/g, replaceCallback);
    nodes.replaceText(/\b(\S+?)\b/g, replaceCallback);
}

//Credit to: http://benalman.com/projects/jquery-replacetext-plugin/
(function ($) {
    $.fn.replaceText = function (b, a, c) {
        return this.each(function () {
            var f = this.firstChild,
                g, e, d = [];
            if (f) {
                do {
                    if (f.nodeType === 3) {
                        g = f.nodeValue;
                        e = g.replace(b, a);
                        if (e !== g) {
                            if (!c && /</.test(e)) {
                                $(f).before(e);
                                d.push(f)
                            } else {
                                f.nodeValue = e
                            }
                        }
                    }
                } while (f = f.nextSibling)
            }
            d.length && $(d).remove()
        })
    }
})(jQuery);