WaniKani Leech Trainer

Study and quiz yourself on your leeches!

// ==UserScript==
// @name         WaniKani Leech Trainer
// @version      1.0.3
// @description  Study and quiz yourself on your leeches!
// @require      https://greasyfork.org/scripts/19781-wanakana/code/WanaKana.js?version=126349
// @author       hitechbunny
// @author       pamput
// @include      https://www.wanikani.com/
// @include      https://www.wanikani.com/dashboard
// @run-at       document-end
// @grant        none
// @namespace https://greasyfork.org/users/149329
// ==/UserScript==

(function() {
    'use strict';

    // Hook into App Store
    try { $('.app-store-menu-item').remove(); $('<li class="app-store-menu-item"><a href="https://community.wanikani.com/t/there-are-so-many-user-scripts-now-that-discovering-them-is-hard/20709">App Store</a></li>').insertBefore($('.navbar .dropdown-menu .nav-header:contains("Account")')); window.appStoreRegistry = window.appStoreRegistry || {}; window.appStoreRegistry[GM_info.script.uuid] = GM_info; localStorage.appStoreRegistry = JSON.stringify(appStoreRegistry); } catch (e) {}

    var css =
        '.noselect {-webkit-touch-callout:none; -webkit-user-select:none; -khtml-user-select:none; -moz-user-select: none;'+
        '-ms-user-select:none; user-select: none;}'+

        '.selfstudy {margin-left:20px; margin-bottom:10px; position:relative;}'+
        '.selfstudy label {display:inline; vertical-align:middle; padding-right:4px; color:#999; font-family:"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif; text-shadow:0 1px 0 #fff;}'+
        '.selfstudy button.enable {width:55px;}'+
        '.ss_active .selfstudy button.enable.on {background-color:#b3e6b3; background-image:linear-gradient(to bottom, #ecf9ec, #b3e6b3);}'+
        '.selfstudy select.config {width:300px;}'+

        '.selfstudy .center {display:block; position:relative; top:50%; left:50%; transform:translate(-50%,-50%);}'+

        'section[id^="level-"].ss_active.ss_hidechar .character-item a span:not(.dummy) {opacity:0; transition:opacity ease-in-out 0.15s}'+
        'section[id^="level-"].ss_active.ss_hideread .character-item a li[lang="ja"] {opacity:0; transition:opacity ease-in-out 0.15s}'+
        'section[id^="level-"].ss_active.ss_hidemean .character-item a li:not([lang="ja"]) {opacity:0; transition:opacity ease-in-out 0.15s}'+
        'section[id^="level-"].ss_active.ss_hideburned .character-item.burned {display:none;}'+
        'section[id^="level-"].ss_active.ss_hidelocked .character-item.locked {display:none;}'+
        'section[id^="level-"].ss_active.ss_hideunburned .character-item:not(.burned) {display:none;}'+
        'section[id^="level-"].ss_active.ss_hideunlocked .character-item:not(.locked) {display:none;}'+

        'section.ss_active .character-item:hover a span {opacity: initial !important; transition:opacity ease-in-out 0.05s !important;}'+
        'section.ss_active .character-item:hover a li {opacity: initial !important; transition:opacity ease-in-out 0.05s !important;}'+

        '#ss_config {position:absolute; z-index:1029; width:573px; background-color:rgba(0,0,0,0.9); border-radius:8px; padding:8px;}'+

        '#ss_config select.configs {width:475px;}'+
        '#ss_config label {color:#ccc; text-shadow:initial; text-align:right; vertical-align:baseline;}'+
        '#ss_config .btns {display:inline-block; float:left; vertical-align:top; margin-right:8px;}'+
        '#ss_config .btns .btn {display:block; margin-bottom:5px;}'+
        '#ss_config .btn {width:70px;}'+

        '#ss_config .list {overflow-x:auto;}'+
        '#ss_config .list select.configs {width:100%; height:135px;}'+

        '#ss_config .section {border-top:1px solid #ccc; padding:0 0 8px 0;}'+
        '#ss_config .section > label {display:block; text-align:left; color:#ffc; font-size:1.2em; font-weight:bold; padding-left:4px; margin-bottom:4px; background-color:#2e2e2e; background-image:linear-gradient(to bottom, #3c3c3c, #1a1a1a); background-repeat:repeat-x;}'+

        '#ss_config .txtline label {display:inline-block; float:left; margin-right:8px; width:100px; line-height:30px; clear:both;}'+
        '#ss_config .txtline .expand {overflow-x:auto;}'+
        '#ss_config .txtline input {box-sizing:border-box; width:100%; height:30px;}'+

        '#ss_config .cbbox {display:inline-block; width:49%; vertical-align:top;}'+
        '#ss_config .cbbox label {display:inline-block; float:left; margin:0 8px 0 0; width:190px; line-height:20px;}'+
        '#ss_config .cbbox input {position:relative; overflow-x:auto; height:20px; margin:0; top:1px;}'+

        '#ss_config [class*="icon-"] {color:#fff;}'+

        '#ss_config .dlg_close {text-align:center; margin-top:16px; margin-bottom:8px;}'+

        '#ss_quiz [lang="ja"] {font-family: "Meiryo","Yu Gothic","Hiragino Kaku Gothic Pro","TakaoPGothic","Yu Gothic","ヒラギノ角ゴ Pro W3","メイリオ","Osaka","MS PGothic","MS Pゴシック",sans-serif;}'+
        '#ss_quiz {position:absolute; z-index:1028; width:573px; background-color:rgba(0,0,0,0.85); border-radius:8px; border:8px solid rgba(0,0,0,0.85); font-size:2em;}'+
        '#ss_quiz * {text-align:center;}'+
        '#ss_quiz .qwrap {height:8em; position:relative; clear:both;}'+

        '#ss_quiz.radicals .qwrap, #ss_quiz.radicals .summary .que {background-color:#0af;}'+
        '#ss_quiz.kanji .qwrap, #ss_quiz.kanji .summary .que {background-color:#f0a;}'+
        '#ss_quiz.vocabulary .qwrap, #ss_quiz.vocabulary .summary .que {background-color:#a0f;}'+

        '#ss_quiz .prev, #ss_quiz .next {display:inline-block; width:80px; color:#fff; line-height:8em; cursor:pointer;}'+
        '#ss_quiz .prev:hover {background-image:linear-gradient(to left, rgba(0,0,0,0), rgba(0,0,0,0.2));}'+
        '#ss_quiz .next:hover {background-image:linear-gradient(to right, rgba(0,0,0,0), rgba(0,0,0,0.2));}'+
        '#ss_quiz .prev {float:left;}'+
        '#ss_quiz .next {float:right;}'+

        '#ss_quiz .topbar {font-size:0.5em; line-height:1em; color: rgba(255,255,255,0.5);}'+

        '#ss_quiz .settings {float:left; padding:6px 8px; text-align:left; line-height:1.5em;}'+
        '#ss_quiz .settings span[class*="icon-"] {font-size:1.3em; padding:0 2px;}'+
        '#ss_quiz .settings .ss_audio {padding-left:0; padding-right:4px;}'+
        '#ss_quiz .settings .ss_typo {padding-left:0px;}'+
        '#ss_quiz .settings .ss_done {font-size:1.25em;}'+
        '#ss_quiz .settings .ss_pair {font-weight:bold;}'+
        '#ss_quiz .settings span {cursor:pointer;}'+
        '#ss_quiz .settings span:hover {color:rgba(255,255,204,0.8);}'+
        '#ss_quiz .settings span.active {color:#ffc;}'+
        '#ss_quiz.help .settings .ss_help {color:#ffc;}'+

        '#ss_quiz .stats_labels {text-align:right; font-family:monospace;}'+
        '#ss_quiz .stats {float:right; text-align:right; color:rgba(255,255,255,0.8); font-family:monospace; padding:0 5px;}'+

        '#ss_quiz .round {display:none; font-weight:bold; position:absolute; box-sizing:border-box; width:60%; height:75%; border-radius:24px; border:2px solid #000; background-color:#fff;}'+
        '#ss_quiz.round .round {display:block;}'+

        '#ss_quiz .question {'+
        '  overflow-x:auto; overflow-y:hidden; position:relative; top:50%; transform:translateY(-50%);'+
        '  color:#fff; text-align:center; line-height:1.1em; font-size:1em; font-weight:bold; cursor:default;'+
        '}'+
        '#ss_quiz .question[data-type="char"] {font-size:2em;}'+
        '#ss_quiz .icon-audio:before {content:"\\f028";}'+
        '#ss_quiz .question .icon-audio {font-size:2.5em; cursor:pointer;}'+
        '#ss_quiz.summary .question {display:none;}'+

        '#ss_quiz .qtype {line-height:2em; cursor:default; text-transform:capitalize;}'+
        '#ss_quiz .qtype.reading {color:#fff; text-shadow:-1px -1px 0 #000; border-top:1px solid #555; border-bottom:1px solid #000; background-color:#2e2e2e; background-image:linear-gradient(to bottom, #3c3c3c, #1a1a1a); background-repeat:repeat-x;}'+
        '#ss_quiz .qtype.meaning {color:#555; text-shadow:-1px -1px 0 rgba(255,255,255,0.1); border-top:1px solid #d5d5d5; border-bottom:1px solid #c8c8c8; background-color:#e9e9e9; background-image:linear-gradient(to bottom, #eee, #e1e1e1); background-repeat:repeat-x;}'+

        '#ss_quiz .help {display:none;'+
        '  position:absolute; top:3%; left:13%; width:74%; box-sizing:border-box; border:2px solid #000; border-radius:15px; padding:4px;'+
        '  color:#555; text-shadow:2px 2px 0 rgba(0,0,0,0.2); background-color:rgba(255,255,255,0.9); font-size:0.8em; line-height:1.2em;'+
        '}'+
        '#ss_quiz.help .help {display:inherit;}'+

        '#ss_quiz .answer {background-color:#ddd; padding:8px;}'+
        '#ss_quiz .answer input {'+
        '  width:100%; background-color:#fff; height:2em; margin:0; border:2px solid #000; padding:0;'+
        '  box-sizing:border-box; border-radius:0; font-size:1em;'+
        '}'+
        '#ss_quiz .answer input.correct {color:#fff; background-color:#8c8; text-shadow:2px 2px 0 rgba(0,0,0,0.2);}'+
        '#ss_quiz .answer input.incorrect {color:#fff; background-color:#f03; text-shadow:2px 2px 0 rgba(0,0,0,0.2);}'+

        '#ss_quiz.loading .qwrap, #ss_quiz.loading .answer {display:none;}'+

        '#ss_quiz .summary {display:none; position:absolute; width:74%; height:100%; background-color:rgba(0,0,0,0.7); color:#fff; font-weight:bold;}'+
        '#ss_quiz.summary .summary {display:block;}'+
        '#ss_quiz .summary h3 {'+
        '  background-image:linear-gradient(to bottom, #3c3c3c, #1a1a1a); background-repeat:repeat-x;'+
        '  border-top:1px solid #777; border-bottom:1px solid #000; margin:0; box-sizing:border-box;'+
        '  text-shadow:2px 2px 0 rgba(0,0,0,0.5); color:#fff; font-size:0.8em; font-weight:bold; line-height:40px;'+
        '}'+
        '#ss_quiz .summary .errors {position:absolute; top:40px; bottom:0px; width:100%; margin:0; overflow-y:auto; list-style-type:none;}'+
        '#ss_quiz .summary li {margin:4px 0 0 0; font-size:0.6em; font-weight:bold; line-height:1.4em;}'+

        '#ss_quiz .summary .errors span {display:inline-block; padding:2px 4px 0px 4px; border-radius:4px; line-height:1.1em; max-width:50%; vertical-align:middle; cursor:pointer;}'+
        '#ss_quiz .summary .ans {background-color:#fff; color:#000;}'+
        '#ss_quiz .summary .wrong {color:#f22;}'+

        '#ss_quiz .btn.requiz {position:absolute; top:6px; right:6px; padding-left:6px; padding-right:6px;}'+

        '#ss_quiz_container {position:absolute; top:0; left:0; width:100%}'+

        '#ss_quiz {position: fixed;    margin-left: auto;    margin-right: auto;    left: 0;    right: 0;    top: 6em;}'+

        '.leech-badge {cursor: pointer;}'+
        '.leech-badge div.popover {display: none !important;}'+

        '#ss_quiz .quiz-progress {margin-bottom: 8px; height: 8px; background-color: gray;}'+

        '#ss_quiz .quiz-progress .quiz-progress-bar {height: 8px; background-color: white;}'+

        '#ss_quiz .quiz-progress .quiz-progress-bar.pulse { animation: pulse 1.5s ease-in-out infinite alternate; }'+
        '@keyframes pulse { 0% { box-shadow: 0px 0px 5px white; } 25% { box-shadow: 0px 0px 20px white; } 75% { box-shadow: 0px 0px 20px white; } 100% { box-shadow: 0px 0px 5px white; } }'+

        '#ss_quiz_abort { position: fixed; top: 0; left: 0; bottom: 0; right: 0; z-index: 999; }'+

        '';

    var quiz_html =
        '<div id="ss_quiz" class="kanji reading">'+
        '  <div class="quiz-progress"><div class="quiz-progress-bar"></div></div>'+
        '  <div class="qwrap">'+
//        '    <div class="prev" title="Previous question (Ctrl-Left)"><i class="icon-chevron-left"></i></div>'+
//        '    <div class="next"><i class="icon-chevron-right"></i></div>'+
        '    <div class="question"></div>'+
        '    <div class="help"></div>'+
        '    <div class="summary center">'+
        '      <h3>Summary - <span class="percent">100%</span> Correct <button class="btn requiz" title="Re-quiz wrong items">Re-quiz</button></h3>'+
        '      <ul class="errors"></ul>'+
        '    </div>'+
        '    <div class="round center"><span class="center">Round 1</span></div>'+
        '  </div>'+
        '  <div class="qtype"></div>'+
        '  <div class="answer"><input type="text" value=""></div>'+
        '</div>';

    $('head').append('<style type="text/css">'+css+'</style>');

    var api_key;
    var quiz;
    var dialog;
    var correct = [];
    var incorrect = [];
    var wanakana_isbound;
    var quizInProgress = false;

    function clear() {
        $('.leech-badge').remove();
        $('<li class="reviews leech-badge"><a><span>&nbsp;</span>Leeches</a></li>').insertAfter('.reviews');
    }

    function query() {
        clear();
        if (localStorage.leech_train_cache) {
            render(JSON.parse(localStorage.leech_train_cache));
        }
        get_api_key().then(function() {
            ajax_retry('https://wanikanitools-golang.curiousattemptbunny.com/leeches/lesson?api_key='+api_key, {timeout: 0}).then(function(json) {
                clear();
                render(json);
            });
        });
    }

    function render(json) {
        localStorage.leech_train_cache = JSON.stringify(json);
        $('.leech-badge a span').html(json.leeches_available);
        if (quizInProgress) {
            return;
        }
        quiz = json.leech_lesson_items;
        $('.leech-badge').click(startQuiz);
    }

    function startQuiz() {
        if (quiz.length === 0) return;
        quizInProgress = true;

        for(var i=0; i<3; i++) {
            shuffle(quiz);
            var last = null;
            var duplicate = false;
            quiz.forEach(function(leech) {
                var key = leech.type + "/" + leech.name;
                if (key == last) {
                    duplicate = true;
                }
                last = key;
            });
            if (!duplicate) break;
        }

        correct = [];
        incorrect = [];

        $('#ss_quiz, #ss_quiz_abort').remove();
        $('body').append(quiz_html).append('<div id="ss_quiz_abort"/>');
        $('.navbar, #search, .dashboard, footer').css('filter', 'blur(20px)');
        wanakana_isbound = false;

        dialog = $('#ss_quiz');

        $('.quiz-progress-bar').css('width', (correct.length*100.0 / (quiz.length))+'%');

        dialog.find('.answer input').on('keypress', onKeyPress);

        $('#ss_quiz_abort').click(function() {
            $('.navbar, #search, .dashboard, footer').css('filter', 'none');
            $('#ss_quiz, #ss_quiz_abort').remove();
            quizInProgress = false;
            query();
        });

        show_next();
    }

    function onKeyPress(e) {
        var code = e.originalEvent.code || String.fromCharCode(e.charCode);
        if (code === 'Enter') {
            var answerGiven = $('#ss_quiz .answer input').val().trim();
            if (e.ctrlKey) answerGiven = quiz[0].correct_answers[0];
            if (answerGiven.length === 0) return;
            if (quiz[0].train_type == 'reading') {
                answerGiven = wanakana.toHiragana(answerGiven).trim();
                if (answerGiven.indexOf("n") == answerGiven.length-1) {
                    answerGiven = answerGiven.substring(0,answerGiven.length-1)+"ん";
                }
            }
            $('#ss_quiz .answer input').val(answerGiven);
            var correctAnswers = quiz[0].correct_answers;
            var tryAgainAnswers = quiz[0].try_again_answers;

            var matches = function(answer) {
                if (quiz[0].train_type == 'reading') {
                    return answer == answerGiven;
                } else {
                    return jw_distance(answer.toLowerCase(), answerGiven.toLowerCase()) > 0.9;
                }
            };
            if (correctAnswers.filter(matches).length > 0) {
                $('#ss_quiz .answer input').addClass('correct').blur();
                correct.push(quiz[0].leech);
                quiz = quiz.slice(1);
                if (e.ctrlKey) {
                    show_next();
                } else {
                    setTimeout(show_next, 750);
                }
            } else if (tryAgainAnswers.filter(matches).length > 0) {
                shake($('#ss_quiz .answer input'));
            } else {
                shake($('#ss_quiz .answer input'), function(e) {if (e) e.focus()});
                dialog.find('.help').html('<a href="/'+quiz[0].type+'/'+quiz[0].name+'" target="_blank">'+quiz[0].correct_answers[0]+'</a>').attr('lang','ja').show();

                incorrect.push(quiz[0].leech);
            }
        } else {
            dialog.find('.help').hide();
        }
        $('.quiz-progress-bar').animate({width: (correct.length*100.0 / (correct.length+quiz.length))+'%'}, 250);
    }

    function shake(elem, callback) {
        var dist = '25px';
        var speed = 75;
        var right = {padding:'0 '+dist+' 0 0'}, left = {padding:'0 0 0 '+dist}, center = {padding:"0 0 0 0"};

        if (callback) callback = callback.bind(null, elem);

        elem.animate(left,speed/2).animate(right,speed)
            .animate(left,speed).animate(right,speed)
            .animate(left,speed).animate(center,speed/2, callback);
    }

    function show_next() {
        if (quiz.length === 0) {
            $('.quiz-progress-bar').addClass('pulse');
            $('#ss_quiz_abort').css('z-index', 1031);

            var trainedLeeches = [];
            correct.forEach(function(leech) {
                if (!trainedLeeches.find(function(l) { return l.key == leech.key; }) && !incorrect.find(function(l) { return l.key == leech.key; })) {
                    trainedLeeches.push(leech);
                }
            });
//            console.log("CORRECT", correct);
//            console.log("WRONG", incorrect);
//            console.log("TRAINED", trainedLeeches);
            var msg = (trainedLeeches.length === 0 ? "Sorry. No leeches trained." : trainedLeeches.length+" leech"+(trainedLeeches > 1 ? "es" : "")+" trained!");
            dialog.find('.help').html(msg).attr('lang','en').show();

            var extras = JSON.parse(window.localStorage['leeches-trained'] || '{}');
            Object.keys(extras).forEach(function(key) {
                if (!trainedLeeches.find(function(l) { return l.key == key; }) && !incorrect.find(function(l) { return l.key == key; })) {
                    trainedLeeches.push({key: key, worst_incorrect: extras[key]});
                }
            });
            console.log(trainedLeeches);
            ajax_retry('https://wanikanitools-golang.curiousattemptbunny.com/leeches/trained?api_key='+api_key, {data: JSON.stringify(trainedLeeches), method: 'POST', timeout: 0}).then(function(json) {
                console.log(json);
                delete window.localStorage['leeches-trained'];
            });

            delete localStorage.leech_train_cache;
            clear();
            return;
        }

        var item = quiz[0];
        var qtype = 'char';
        var qlang = 'ja';
        var qtext = item.name;
        var atype = item.train_type;
        var alang = 'ja';
        var itype = item.type;

        //console.log(item.readings);

        dialog.find('.question').attr('data-type', qtype).attr('lang',qlang).html(qtext);
        var type_text = itype + ' <strong>'+atype+'</strong>';
        dialog.find('.qtype').removeClass('reading meaning').addClass(atype).html(type_text);
        dialog.removeClass('kanji vocabulary').addClass(itype);

        $('#ss_quiz .answer input').attr('lang',alang).removeClass('correct').val('').focus().select();

        if (atype === 'reading') {
            if (!wanakana_isbound) {
                wanakana.bind($('#ss_quiz .answer input')[0]);
                wanakana_isbound = true;
            }
        } else {
            if (wanakana_isbound) {
                wanakana.unbind($('#ss_quiz .answer input')[0]);
                wanakana_isbound = false;
            }
        }
    }

    query();

    function shuffle(array) {
        var i = array.length, j, temp;
        if (i===0) return array;
        while (--i) {
            j = Math.floor(Math.random()*(i+1));
            temp = array[i]; array[i] = array[j]; array[j] = temp;
        }
        return array;
    }

    // Jaro-Winkler Distance
    function jw_distance(a, c) {
        var h, b, d, k, e, g, f, l, n, m, p;
        if (a.length > c.length) {
            c = [c, a];
            a = c[0];
            c = c[1];
        }
        k = ~~Math.max(0, c.length / 2 - 1);
        e = [];
        g = [];
        b = n = 0;
        for (p = a.length; n < p; b = ++n) {
            for (h = a[b], l = Math.max(0, b - k), f = Math.min(b + k + 1, c.length), d = m = l; l <= f ? m < f : m > f; d = l <= f ? ++m : --m) {
                if (g[d] === undefined && h === c[d]) {
                    e[b] = h;
                    g[d] = c[d];
                    break;
                }
            }
        }
        e = e.join("");
        g = g.join("");
        d = e.length;
        if (d) {
            b = f = k = 0;
            for (l = e.length; f < l; b = ++f) {
                h = e[b];
                if (h !== g[b]) k++;
            }
            b = g = e = 0;
            for (f = a.length; g < f; b = ++g) {
                if (h = a[b], h === c[b])
                    e++;
                else
                    break;
            }
            a = (d/a.length + d/c.length + (d - ~~(k/2))/d)/3;
            a += 0.1 * Math.min(e, 4) * (1 - a);
        } else {
            a = 0;
        }
        return a;
    }

    //-------------------------------------------------------------------
    // Fetch a document from the server.
    //-------------------------------------------------------------------
    function ajax_retry(url, options) {
        //console.log(url, retries, timeout);
        options = options || {};
        var retries = options.retries || 3;
        var timeout = options.timeout || 3000;
        var headers = options.headers || {};
        var method = options.method || 'GET';
        var data = options.data || undefined;
        var cache = options.cache || false;

        function action(resolve, reject) {
            $.ajax({
                url: url,
                method: method,
                timeout: timeout,
                headers: headers,
                data: data,
                cache: cache
            })
            .done(function(data, status){
                //console.log(status, data);
                if (status === 'success') {
                    resolve(data);
                } else {
                    //console.log("done (reject)", status, data);
                    reject();
                }
            })
            .fail(function(xhr, status, error){
                //console.log(status, error);
                if ((status === 'error' || status === 'timeout') && --retries > 0) {
                    //console.log("fail", status, error);
                    action(resolve, reject);
                } else {
                    reject();
                }
            });
        }
        return new Promise(action);
    }

    function get_api_key() {
        return new Promise(function(resolve, reject) {
            api_key = localStorage.getItem('apiKey_v2');
            if (typeof api_key === 'string' && api_key.length == 36) return resolve();

            // status_div.html('Fetching API key...');
            ajax_retry('/settings/account').then(function(page) {

                // --[ SUCCESS ]----------------------
                // Make sure what we got is a web page.
                if (typeof page !== 'string') {return reject();}

                // Extract the user name.
                page = $(page);

                // Extract the API key.
                api_key = page.find('#user_api_key_v2').attr('value');
                if (typeof api_key !== 'string' || api_key.length !== 36) {
                    return reject(new Error('generate_apikey'));
                }

                localStorage.setItem('apiKey_v2', api_key);
                resolve();

            },function(result) {
                // --[ FAIL ]-------------------------
                reject(new Error('Failed to fetch API key!'));

            });
        });
    }
})();