[Userscript] Self-Study Quiz

@rfindley I know you’re busy, but do you have any sense of when you might update the script to let other scripts plug in their own quiz data? I’d love to be able to use @hitechbunny’s leech data in the self study script.

Side note: I calculated recently that about 20% of my WaniKani time is spent reviewing leeches, so I really need to dedicate some time on it. Even if your update won’t be ready soon, I’ll probably spend some self study time on the leeches. It will just be more painful of an experience. :wink:

1 Like

It will be at least a few weeks. I’m working on the data framework right now.

3 Likes

@seanblue I have some plans for Leeches Training :) - #4 by Allquest. In the meantime, if you use Anki then you can download your leeches in a format Anki can use:

I don’t really like Anki’s default behavior/look and customizing it is daunting. I try to avoid it as much as possible.

1 Like

To anyone interested, I threw together a modified version of the self study script that allows for studying more than level at once. Additionally, you can specify a “time until review” for each SRS level so that you don’t accidentally study too close to a review. Detailed explanation and variable settings toward the top of the file.

I will not actively support this version of the script, but I figured I’d share.

// ==UserScript==
// @name        Wanikani Self-Study Quiz Edition
// @namespace   rfindley
// @description Self-study your items via the Wanikani level pages
// @version     2.0.20
// @include     https://www.wanikani.com/level/*
// @exclude     https://www.wanikani.com/level/*/*
// @include     https://www.wanikani.com/radicals*
// @exclude     https://www.wanikani.com/radicals/*
// @include     https://www.wanikani.com/kanji*
// @exclude     https://www.wanikani.com/kanji/*
// @include     https://www.wanikani.com/vocabulary*
// @exclude     https://www.wanikani.com/vocabulary/*
// @require     https://greasyfork.org/scripts/19781-wanakana/code/WanaKana.js?version=126349
// @copyright   2016+, Robin Findley
// @license     MIT; http://opensource.org/licenses/MIT
// @run-at      document-end
// @grant       none
// ==/UserScript==

window.wkselfstudy = {};

(function(gobj) {
	// HOW IT WORKS //

	// No longer filters quiz using "Remove Unlocked", "Remove Burned", or "Remove Unburned". These only impact the UI.
	// Still filters by "Remove Locked". Keep this unchchecked to do pre-studying.
	// Also filters by "time left until review", as specified by the variable 'daysUntilReviewAllowedToStudy' below.

	// Will do a quiz for more than one level based on the level selected and the value of the variable 'levelsToQuizAtOnce'.
	// For example, if 'levelsToQuizAtOnce' is set to 5, clicking the "Quiz" button for any level from 11 to 15 will quiz all levels from 11 to 15 at once.

	// END HOW IT WORKS //

	// SETTINGS //

	// How many levels to self study at once.
	var levelsToQuizAtOnce = 5;

	// Specify number of days left until a review to allow for self study.
	// Setting the number to the full interval means that SRS level will never show up in self study.
	var daysUntilReviewAllowedToStudy = {
		1: 0, // Full interval is 4 hours
		2: 0, // Full interval is 8 hours
		3: 0.75, // Full interval is 1 day
		4: 1.5, // Full interval is 2 days
		5: 5, // Full interval is 7 days
		6: 10, // Full interval is 14 days
		7: 25, // Full interval is 30 days
		8: 120 // Full interval is 120 days
	};

	// END SETTINGS //

    var settings = {
        compatible: 2,
        // ss_hidelocked   - Hide locked items
        // ss_hideunlocked - Hide unlocked items (includes ss_hideburn)
        // ss_hideburned   - Hide burned items
        // ss_hideunburned - Hide unburned items (includes ss_hidelock)
        // ss_hidechar     - Hide the radical/kanji/vocab characters
        // ss_hideread     - Hide the reading
        // ss_hidemean     - Hide the meaning
        // ss_quizctom     - Character -> Meaning
        // ss_quizctor     - Character -> Reading
        // ss_quizrtom     - Reading -> Meaning
        // ss_quizmtor     - Meaning -> Reading
        // ss_quizator     - Audio -> Reading
        // ss_quizatom     - Audio -> Meaning
        configs: [
            ['Japanese to English',      'ss_hidelocked ss_hideread ss_hidemean ss_quizctom ss_quizctor'],
            ['English to Japanese',      'ss_hidelocked ss_hideread ss_hidechar ss_quizmtor'],
            ['[BURNED] Japanese to English', 'ss_hideunburned ss_hideread ss_hidemean ss_quizctom ss_quizctor'],
            ['[BURNED] English to Japanese', 'ss_hideunburned ss_hideread ss_hidechar ss_quizmtor'],
            ['Listening Quiz',           'ss_hidelocked ss_hideread ss_hidechar ss_hidemean ss_quizator ss_quizator'],
        ],
        selected_config: 0,
        enabled: true,
        randomize_on_load: true,
        lightning_mode: false, // Skip 'correct', jump to next item.
        audio_mode: false, // Auto-play audio files (i.e. readings).
        quiz_pairing: 1, // 0=none, 1=Reading first, 2=Meaning first
        quiz_repeat: true, // Repeat after finishing quiz.
        quiz_shuffle: true, // Shuffle before repeating quiz.
        quiz_typo: true // Allow typos in English answers
    };
    gobj.settings = settings;

    var html =
        '<div class="selfstudy">'+
        '  <label>Self-study:</label>'+
        '  <div class="btn-group">'+
        '    <button class="btn enable" title="Enable/Disable self-study plugin">OFF</button>'+
        '    <button class="btn quiz" title="Open the quiz window">Quiz</button>'+
        '    <button class="btn shuffle" title="Shuffle the list of items below">Shuffle</button>'+
        '    <select class="btn config" title="Select a self-study preset"></select>'+
        '    <button class="btn config" title="Configure self-study presets"><i class="icon-gear"></i></button>'+
        '  </div>'+
        '</div>';

    var config_html =
        '<div id="ss_config" class="hidden">'+
        '  <div class="section"><label>Presets</label>'+
        '    <div class="btns">'+
        '      <button class="btn new">New</button>'+
        '      <button class="btn up">Up</button>'+
        '      <button class="btn dn">Down</button>'+
        '      <button class="btn del">Delete</button>'+
        '    </div>'+
        '    <div class="list">'+
        '      <select class="configs" size="7"></select>'+
        '    </div>'+
        '    <div class="hide_cfg">'+
        '      <div class="txtline">'+
        '        <label>Edit name:</label>'+
        '        <div class="expand"><input type="text" class="preset"></div>'+
        '      </div>'+
        '    </div>'+
        '  </div>'+
        '  <div class="section"><label>Items</label>'+
        '    <div class="cbbox">'+
        '      <div><label>Remove Locked:</label><input type="checkbox" name="ss_hidelocked"></div>'+
        '      <div><label>Remove Unlocked:</label><input type="checkbox" name="ss_hideunlocked"></div>'+
        '    </div>'+
        '    <div class="cbbox">'+
        '      <div><label>Remove Burned:</label><input type="checkbox" name="ss_hideburned"></div>'+
        '      <div><label>Remove Unburned:</label><input type="checkbox" name="ss_hideunburned"></div>'+
        '    </div>'+
        '  </div>'+
        '  <div class="section"><label>Information</label>'+
        '    <div class="cbbox">'+
        '      <div><label>Hide Rad/Kan/Voc:</label><input type="checkbox" name="ss_hidechar"></div>'+
        '    </div>'+
        '    <div class="cbbox">'+
        '      <div><label>Hide Reading:</label><input type="checkbox" name="ss_hideread"></div>'+
        '      <div><label>Hide Meaning:</label><input type="checkbox" name="ss_hidemean"></div>'+
        '    </div>'+
        '  </div>'+
        '  <div class="section"><label>Quiz</label>'+
        '    <div class="cbbox">'+
        '      <div><label>Rad/Kan/Voc <i class="icon-circle-arrow-right"></i> Meaning:</label><input type="checkbox" name="ss_quizctom"></div>'+
        '      <div><label>Kan/Voc <i class="icon-circle-arrow-right"></i> Reading:</label><input type="checkbox" name="ss_quizctor"></div>'+
        '      <div><label>Reading <i class="icon-circle-arrow-right"></i> Meaning:</label><input type="checkbox" name="ss_quizrtom"></div>'+
        '      <div><label>Meaning <i class="icon-circle-arrow-right"></i> Reading:</label><input type="checkbox" name="ss_quizmtor"></div>'+
        '    </div>'+
        '    <div class="cbbox">'+
        '      <div><label>Voc Audio <i class="icon-circle-arrow-right"></i> Reading:</label><input type="checkbox" name="ss_quizator"></div>'+
        '      <div><label>Voc Audio <i class="icon-circle-arrow-right"></i> Meaning:</label><input type="checkbox" name="ss_quizatom"></div>'+
        '    </div>'+
        '  </div>'+
        '  <div class="dlg_close">'+
        '    <div class="btn-group">'+
        '      <button class="btn save">Save</button>'+
        '      <button class="btn cancel">Cancel</button>'+
        '    </div>'+
        '  </div>'+
        '</div>';

    var quiz_html =
        '<div id="ss_quiz" class="hidden kanji meaning">'+
        '  <div class="topbar">'+
        '    <div class="settings noselect">'+
        '      <span class="icon-bolt ss_lightning" title="Lightning Mode: Skip <enter> on correct answers (Ctrl-L)"></span>'+
        '      <span class="icon-retweet ss_repeat" title="Repeat after finishing quiz (Ctrl-R)"></span>'+
        '      <span class="icon-random ss_shuffle" title="Shuffle before repeating quiz (Ctrl-S)"></span>'+
        '      <span class="icon-audio ss_audio" title="Auto-play audio (Ctrl-Shift-A; Ctrl-A to play)"></span>'+
        '      <span class="icon-warning-sign ss_typo" title="Allow typos (oops) in English answers (Ctrl-O)" style="padding-left: 0px;"></span>'+
        '      <span class="icon-question-sign ss_help" title="Help: Peek at item info (F1, Ctrl-H, or ?)"></span>'+
        '      <span class="ss_done" title="End the quiz and show summary (Esc or Ctrl-E)"><strong>%</strong></span><br />'+
        '      <span class="ss_pair" data-value="0" title="Pairing mode: Group reading and meaning together (Ctrl-P)">Pairing: <span class="data">Disabled</span></span>'+
        '    </div>'+
        '    <div class="stats"></div>'+
        '    <div class="stats_labels">Round:<br>Remaining:<br>Correct:<br>Incorrect:</div>'+
        '  </div>'+
        '  <div class="qwrap">'+
        '    <div class="prev" title="Previous question (Ctrl-Left)"><i class="icon-chevron-left"></i></div>'+
        '    <div class="next" title="Next question (Ctrl-Right)"><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>';

    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;}'+

        '';

    var cfg_tmp;

    // 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;
    }

    //-------------------------------------------------------------------
    // Open the configuration dialog.
    //-------------------------------------------------------------------
    function configure(e) {

        var sel, ssgrp, dialog;

        function setup() {
            dialog = $(config_html).appendTo(ssgrp);
            sel = $('#ss_config select.configs');

            // "New" handler
            dialog.find('button.new').on('click', function() {
                cfg_tmp.push(['<new>','']);
                sel.append('<option value="'+(cfg_tmp.length-1)+'">&lt;new&gt;</option>');
                select_config(sel.children().length-1);
                $('#ss_config .preset').focus().select();
            });

            // "Delete" handler
            dialog.find('button.del').on('click', function() {
                var opt = sel.find(':selected');
                var idx = opt.index();
                opt.remove();
                var len = sel.children().length;
                if (idx >= len) idx = len-1;
                select_config(idx);
            });

            // "Up" handler
            dialog.find('button.up').on('click', function() {
                var opt = sel.find(':selected');
                if (opt.index() > 0) opt.insertBefore(opt.prev());
            });

            // "Down" handler
            dialog.find('button.dn').on('click', function() {
                var opt = sel.find(':selected');
                if (opt.index() < sel.children().length-1) opt.insertAfter(opt.next());
            });

            // "Configs" selection changed
            sel.on('change', function() {
                select_config(sel.find(':selected').index());
            });

            // "Preset" name changed
            dialog.find('.preset').on('change', function(e) {
                var opt = sel.find(':selected');
                var text = e.currentTarget.value;
                opt.text(text);
                var idx = opt.val();
                cfg_tmp[idx][0] = text;
            });

            // "Checkbox" changed
            dialog.find('input[type="checkbox"]').on('change', function() {
                var opt = sel.find(':selected');
                var idx = opt.val();
                var props = [];
                dialog.find('input[type="checkbox"]:checked').each(function(i,e){props.push(e.name);});
                cfg_tmp[idx][1] = props.join(' ');
            });

            // "Save" handler
            dialog.find('button.save').on('click', save_config);

            // "Cancel" handler
            dialog.find('button.cancel').on('click', cancel_config);
        }

        function save_config() {
            settings.configs = [];
            sel.children().each(function(i,v){
                var idx = $(v).val();
                settings.configs.push(cfg_tmp[idx].slice(0));
            });
            settings.selected_config = sel.find(':selected').index();
            save_settings();
            dialog.addClass('hidden');
            populate_presets();
            set_config(settings.selected_config);
        }

        function cancel_config() {
            cfg_tmp = undefined;
            dialog.addClass('hidden');
        }

        function select_config(idx) {
            var opt = sel.children().eq(idx);
            opt.prop('selected',true);
            $('#ss_config input.preset').val(opt.text());

            var props = cfg_tmp[opt.val()][1];
            $('#ss_config .cbbox input').prop('checked', false);
            props.split(' ').forEach(function(prop,i){
                $('#ss_config .cbbox input[name="'+prop+'"]').prop('checked', true);
            });
        }

        ssgrp = $(e.currentTarget).closest('.selfstudy');
        dialog = $('#ss_config');
        if (dialog.length === 0) {
            setup();
        } else if (dialog.is(':visible')) {
            return cancel_config();
        } else {
            ssgrp.append(dialog);
            sel = $('#ss_config select.configs');
        }

        // Clone the existing settings.
        var options = [];
        cfg_tmp = settings.configs.map(function(e,i){
            options.push('<option value="'+i+'">'+e[0]+'</option>');
            return e.slice(0);
        });

        // Populate configs.
        sel.html(options.join(''));
        select_config(settings.selected_config);

        // Unhide the config dialog.
        var top = ssgrp.find('.btn-group').height() + 4;
        dialog.css('top',top).removeClass('hidden');
    }

    //-------------------------------------------------------------------
    // Save settings.
    //-------------------------------------------------------------------
    function save_settings() {
        localStorage.setItem('selfstudy_settings', JSON.stringify(settings));
    }

    //-------------------------------------------------------------------
    // Button event handler.
    //-------------------------------------------------------------------
    function toggle_enable() {
        settings.enabled = !settings.enabled;
        save_settings();
        set_enable();
    }

    //-------------------------------------------------------------------
    // Button event handler.
    //-------------------------------------------------------------------
    function config_change_event(e) {
        set_config(Number(e.currentTarget.value));
    }

    //-------------------------------------------------------------------
    // Add a shuffle function to Array and jQuery.
    //-------------------------------------------------------------------
    function fisher_yates_shuffle() {
        var i = this.length, j, temp;
        if (i===0) return this;
        while (--i) {
            j = Math.floor(Math.random()*(i+1));
            temp = this[i]; this[i] = this[j]; this[j] = temp;
        }
        return this;
    }
    if (typeof Array.prototype.shuffle !== 'function') Array.prototype.shuffle = fisher_yates_shuffle;
    $.fn.shuffle = fisher_yates_shuffle;

    //-------------------------------------------------------------------
    // Shuffle items.
    //-------------------------------------------------------------------
    function shuffle(e) {
        if (e === undefined) {
            // Shuffle all
            $('section[id^="level-"]').each(function(){
                var sec = $(this);
                sec.find('[class$="-character-grid"]').append(sec.find('.character-item').detach().shuffle());
            });
        } else {
            // Shuffle specific group
            var btn = $(e.currentTarget);
            var sec = btn.closest('section[id^="level-"]');
            sec.find('[class$="-character-grid"]').append(sec.find('.character-item').detach().shuffle());
            quiz.refresh();
        }
    }

    //-------------------------------------------------------------------
    // Enable or disable the plugin.
    //-------------------------------------------------------------------
    function set_enable() {
        var btns = $('.selfstudy button.enable');
        var secs = $('section[id^="level-"]');

        if (settings.enabled) {
            secs.addClass('ss_active');
            btns.addClass('on').text('ON');
        } else {
            secs.removeClass('ss_active');
            btns.removeClass('on').text('OFF');
        }
    }

    //-------------------------------------------------------------------
    // Select a configuration.
    //-------------------------------------------------------------------
    function set_config(val) {
        var secs = $('section[id^="level-"]');

        // Remove all ss_* classes except ss_alive
        secs.each(function(i,e){
            e.className = e.className.split(' ').filter(function(v){return (v.match(/^ss_(?!active)/) === null);}).join(' ');
        });

        settings.selected_config = val;
        save_settings();
        $('.selfstudy select.config').val(val);

        settings.configs[settings.selected_config][1].split(' ').forEach(function(cfgopt,idx){
            secs.addClass(cfgopt);
        });
        quiz.refresh();
    }

    //-------------------------------------------------------------------
    // Populate the presets into the drop-down box.
    //-------------------------------------------------------------------
    function populate_presets() {
        var options = [];
        settings.configs.forEach(function(config,idx){
            var cfgname = config[0];
            var cfgopts = config[1];
            options.push('<option value="'+idx+'">'+cfgname+'</option>');
        });
        $('.selfstudy select.config').html(options.join(''));
    }

    //-------------------------------------------------------------------
    // Make first letter of each word upper-case.
    //-------------------------------------------------------------------
    function toTitleCase(str) {
        return str.replace(/\w\S*/g, function(txt){return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();});
    }

    //-------------------------------------------------------------------
    // Trim surrounding whitespace
    //-------------------------------------------------------------------
    function trim(str) {
        return str.replace(/^\s*|\s*$/g,'');
    }

    var quiz = {};
    gobj.quiz = quiz;

    (function (quiz) {
        var ssgrp, sec, dialog, apikey, itype, ichar, level, items, order, requiz_order, wanakana_isbound = false;
        var round, correct, incorrect, quiz_idx, quiz_max, answered;
        var items_cache = {radicals:[],kanji:[],vocabulary:[]};
        var good_answers, all_answers, alang, qlang, atype, qtype, force_summary;

        // Make the items cache accessible from the console (via wkselfstudy.items_cache)
        gobj.items_cache = items_cache;

        //-------------------------------------------------------------------
        // Quiz on all the items in the selected section.
        //-------------------------------------------------------------------
        quiz.open = function(e) {
            if (e !== undefined && setup(e) === 'closed') return;
            fetch_data();
        };
        quiz.close = function() {
            dialog.addClass('hidden');
            $('body').off('.ss_quiz');
        };
        quiz.is_open = function() {
            var dlg = $('#ss_quiz');
            if (dlg.length===0 || dlg.hasClass('hidden')) return false;
            return true;
        };
        quiz.refresh = function() {
            if (!quiz.is_open()) return;
            quiz.open();
        };

        function reset_answer() {
            $('#ss_quiz .answer input').prop('readonly',false).removeClass('correct incorrect').val('').focus();
        }

        function set_pairing(value) {
            var text = [
                'Disabled',
                'Reading first',
                'Meaning first'
            ][settings.quiz_pairing];
            $('#ss_quiz .ss_pair').attr('data-value',settings.quiz_pairing).children('.data').text(text);
            quiz.refresh();
        }

        function set_help(value) {
            if (value) {
                dialog.addClass('help');
            } else {
                dialog.removeClass('help');
            }
        }

        function toggle_pair() {
            var elem = $('#ss_quiz .settings .ss_pair');
            var pairing = (Number(elem.attr('data-value'))+1)%3;
            settings.quiz_pairing = pairing;
            set_pairing(pairing);
            save_settings();
        }

        function toggle_lightning() {
            var elem = $('#ss_quiz .settings .ss_lightning');
            elem.toggleClass('active');
            settings.lightning_mode = elem.hasClass('active');
            save_settings();
        }

        function toggle_audio() {
            var elem = $('#ss_quiz .settings .ss_audio');
            elem.toggleClass('active');
            settings.audio_mode = elem.hasClass('active');
            if (settings.audio_mode && itype==='vocabulary' && atype==='reading') audio.load();
            save_settings();
        }

        function toggle_repeat() {
            var elem = $('#ss_quiz .settings .ss_repeat');
            elem.toggleClass('active');
            settings.quiz_repeat = elem.hasClass('active');
            save_settings();
        }

        function toggle_shuffle() {
            var elem = $('#ss_quiz .settings .ss_shuffle');
            elem.toggleClass('active');
            settings.quiz_shuffle = elem.hasClass('active');
            save_settings();
        }

        function toggle_typo() {
            var elem = $('#ss_quiz .settings .ss_typo');
            elem.toggleClass('active');
            settings.quiz_typo = elem.hasClass('active');
            save_settings();
        }

        function toggle_help() {
            if (quiz_idx < 0 || quiz_idx > quiz_max) return;
            $('#ss_quiz').toggleClass('help');
            if (settings.audio_mode && $('#ss_quiz').hasClass('help') && itype==='vocabulary' && atype==='reading') {
                audio.play();
            }
        }

        var audio = {
            urls: [],
            name: '',
            level: 0,
            request_load: false,
            loaded: false,

            clear: function() {
                audio.name = '';
                audio.level = 0;
                audio.request_load = false;
                audio.loaded = false;

                dialog.find('audio.old').remove();
                dialog.find('audio').each(function(i,tag){
                    if (tag.paused) {
                        $(tag).remove();
                    } else {
                        $(tag).addClass('old');
                        tag.onended = function(event) {
                            $(event.target).remove();
                        }
                    }
                });
            },

            load_urls: function(level) {
                if (audio.urls[level] !== undefined) return;
                $.getJSON('https://www.idigtech.com/wanikani/json/audio_urls/'+level+'.json', function(json, status, xhr){
                    if (status !== 'success') return;
                    audio.urls[level] = json;
                    if (audio.request_load) {
                        audio.set(audio.name, audio.level, audio.request_load);
                    }
                });
            },

            load: function() {
                if (audio.loaded) return;
                dialog.find('audio:not(.old)').each(function(i,tag){
                    tag.load();
                    audio.request_load = false;
                    audio.loaded=true;
                });
            },

            set: function(name, level, preload) {
                if (name !== audio.name) audio.loaded = false;
                if (audio.loaded) return;
                audio.clear();
                audio.name = name;
                audio.level = level;
                audio.request_load = preload;
                if (audio.urls[level] === undefined) return audio.load_urls(level);
                dialog.append('<audio><source src="'+audio.urls[level][name]+'" type="audio/mpeg"></audio>');
                if (audio.request_load) audio.load();
            },

            play: function() {
                if (!audio.loaded) audio.load();
                dialog.find('audio:not(.old)').each(function(i,tag){
                    if (!tag.paused) {
                        tag.currentTime = 0;
                    } else {
                        tag.play();
                    }
                });
            }
        };
        quiz.audio = audio;

        function goto_summary() {
            quiz_idx = quiz_max;
            force_summary = true;
            next_question();
        }

        function settings_handler(e) {
            var cname = $(e.currentTarget).attr('class').match(/\bss_\S*\b/)[0];

            switch (cname) {
                case 'ss_pair': toggle_pair(); break;
                case 'ss_lightning': toggle_lightning(); break;
                case 'ss_audio': toggle_audio(); break;
                case 'ss_repeat': toggle_repeat(); break;
                case 'ss_shuffle': toggle_shuffle(); break;
                case 'ss_typo': toggle_typo(); break;
                case 'ss_help': toggle_help(); break;
                case 'ss_done': goto_summary(); break;
            }
        }

        function scroll_errors(e) {
            var t = e.currentTarget;

            // If scrollbar is visible...
            if (t.scrollHeight > t.clientHeight) {
                var delta = e.originalEvent.deltaY;

                // ...and we are scrolling beyond the limit...
                if ((delta < 0 && (t.scrollTop <= 0)) ||
                    (delta > 0 && (t.scrollTop+t.clientHeight >= t.scrollHeight))) {

                    // ...prevent scroll from bubbling to window.
                    e.preventDefault();
                    e.stopPropagation();
                }
            }
        }

        function requiz(e) {
            order = requiz_order.shuffle().slice(0);
            requiz_order = [];
            round = 1;
            correct = 0;
            incorrect = 0
            answered = false;
            quiz_idx = 0;
            quiz_max = order.length-1;
            force_summary = false;
            dialog.find('.errors').html('');
            do_quiz();
        }

        function setup(e) {
            ssgrp = $(e.currentTarget).closest('.selfstudy');
            sec = $(e.currentTarget).closest('section[id^="level-"]');
            var id = sec.attr('id').split('-');
            level = id[1];
            itype = window.location.pathname.split('/')[1];
            if (itype === 'level') itype = id[2];

            dialog = $('#ss_quiz');
            if (dialog.length === 0) {
                dialog = $(quiz_html).appendTo(ssgrp);

                // "Prev" button handler
                dialog.find('.prev').on('click', prev_question);

                // "Next" button handler
                dialog.find('.next').on('click', next_question);

                // "Enter" handler for answer
                dialog.find('.answer input').on('keydown keypress', quiz_key);

                // "Enter" handler for answer
                dialog.find('.settings').on('click', '>span', settings_handler);

                // Handle scrollbar inside errors window.
                dialog.find('.errors').on('wheel', scroll_errors);

                // "Re-quiz" handler
                dialog.find('.summary .requiz').on('click', requiz);

                // Audio-click handler
                dialog.find('.question').on('click', '.icon-audio', audio.play);

            } else if (dialog.is(':visible')) {
                if (ssgrp.find('#ss_quiz').length === 0) {
                    ssgrp.append(dialog);
                } else {
                    dialog.addClass('hidden');
                    return 'closed';
                }
            } else {
                ssgrp.append(dialog);
            }
            if (settings.lightning_mode) dialog.find('.ss_lightning').addClass('active');
            if (settings.audio_mode) dialog.find('.ss_audio').addClass('active');
            if (settings.quiz_repeat) dialog.find('.ss_repeat').addClass('active');
            if (settings.quiz_shuffle) dialog.find('.ss_shuffle').addClass('active');
            if (settings.quiz_typo) dialog.find('.ss_typo').addClass('active');
            set_pairing(settings.quiz_pairing);
        }

        function show_error() {
            console.log('wkselfstudy: Failed to get API key!');
        }

        function is_apikey_valid(apikey) {
            return (apikey !== null && apikey.match(/^[0-9a-f]{32}$/) !== null);
        }

        function get_apikey() {
            apikey = localStorage.getItem('apiKey');

            if (is_apikey_valid(apikey)) return true;

            $.get('/settings/account')
            .done(function(page, status, xhr){
                if (status !== 'success') return show_error();
                apikey = $(page).find('#user_api_key').attr('value');
                if (apikey === undefined || apikey.match(/^[0-9a-f]{32}$/) === null) return show_error();
                localStorage.setItem('apiKey', apikey);
                fetch_data();
            })
            .fail(show_error);

            return false;
        }

        function fetch_data() {
            round = 1;
            correct = 0;
            incorrect = 0;
            answered = false;
            quiz_idx = 0;
            force_summary = false;
            dialog.find('.summary .errors').html('');
            if (!get_apikey()) return;

			let levelStart = Math.floor((level - 1) / levelsToQuizAtOnce) * levelsToQuizAtOnce + 1;
			let promises = [];

			for (let i = 0; i < levelsToQuizAtOnce; i++) {
				let loopLevel = levelStart + i;
				if (itype==='vocabulary') {
					audio.load_urls(loopLevel);
				}

            	if (items_cache[itype][loopLevel] === undefined) {
					let promise = $.getJSON('/api/user/' + apikey + '/' + itype + '/' + loopLevel);
					promises[i] = promise;

					promise.done(function(json, status, xhr) {
						if (status !== 'success')
							return show_error();
						items_cache[itype][loopLevel] = json.requested_information
					});
				}
			}


			$.when.apply($, promises).done(function() {
				create_quiz();
			}).fail(show_error);

			/*
			if (itype==='vocabulary') audio.load_urls(level);
            if (items_cache[itype][level] !== undefined) return create_quiz();

            dialog.attr('class','loading');
            dialog.find('.qtype').removeClass('meaning').addClass('reading').html('<strong>Loading...</strong>');

            $.getJSON('/api/user/' + apikey + '/' + itype + '/' + loopLevel)
            .done(function(json, status, xhr){
                if (status !== 'success') return show_error();
                items_cache[itype][level] = json.requested_information;
                create_quiz();
            })
            .fail(show_error);
			*/
		}

        function is_unlocked(item) {
            return (item.user_specific !== null) && (item.user_specific.unlocked_date !== null) && (item.user_specific.unlocked_date > 0);
        }
        function is_locked(item) {
            return !is_unlocked(item);
        }
        function is_burned(item) {
            return is_unlocked(item) && (item.user_specific.burned === true || item.user_specific.burned_date > 0);
        }
        function is_unburned(item) {
            return !is_burned(item);
        }

        function create_quiz() {
            var char_to_mean, char_to_read, read_to_mean, mean_to_read, aud_to_read, aud_to_mean;

            items = [].concat(...items_cache[itype].filter(function(n){ return n != undefined }));

            // Remove any items that aren't included in the current selection.

			var now = Date.now();
            items = items.filter(function(item) {
				if (is_locked(item))
					return true;

				var nextReviewDate = new Date(item.user_specific.available_date * 1000);
				var daysUntilNextReview = (nextReviewDate - now) / 86400000;

				return daysUntilNextReview > daysUntilReviewAllowedToStudy[item.user_specific.srs_numeric];
			});

            if (sec.hasClass('ss_hidelocked')) items = items.filter(is_unlocked);
            //if (sec.hasClass('ss_hideunlocked')) items = items.filter(is_locked);
            //if (sec.hasClass('ss_hideburned')) items = items.filter(is_unburned);
            //if (sec.hasClass('ss_hideunburned')) items = items.filter(is_burned);

            if (itype==='radicals') {
                char_to_mean = true;
                char_to_read = false;
                read_to_mean = false;
                mean_to_read = false;
                aud_to_mean = false;
            } else {
                char_to_mean = sec.hasClass('ss_quizctom');
                char_to_read = (sec.hasClass('ss_quizctor') && (itype!=='radicals'));
                read_to_mean = sec.hasClass('ss_quizrtom');
                mean_to_read = sec.hasClass('ss_quizmtor');
                aud_to_read = (sec.hasClass('ss_quizator') && (itype==='vocabulary'));
                aud_to_mean = (sec.hasClass('ss_quizatom') && (itype==='vocabulary'));
            }

            var idx, idx2, max = items.length;
            order = []; requiz_order=[];
            var tmp_order = [];
            switch (settings.quiz_pairing) {
                case 0: // No pairing
                    for (idx=0; idx<max; idx++) {
                        if (aud_to_read) order.push([idx,3,1]);
                        if (aud_to_mean) order.push([idx,3,2]);
                        if (char_to_mean) order.push([idx,0,2]);
                        if (char_to_read) order.push([idx,0,1]);
                        if (read_to_mean) order.push([idx,1,2]);
                        if (mean_to_read) order.push([idx,2,1]);
                    }
                    order.shuffle();
                    do_quiz(true /* requeue when wrong */);
                    break;

                case 1: // Reading first
                    for (idx=0; idx<max; idx++) tmp_order.push(idx);
                    tmp_order.shuffle();
                    for (idx2=0; idx2<max; idx2++) {
                        idx = tmp_order[idx2];
                        if (aud_to_read) order.push([idx,3,1]);
                        if (aud_to_mean) order.push([idx,3,2]);
                        if (char_to_read) order.push([idx,0,1]);
                        if (mean_to_read) order.push([idx,2,1]);
                        if (char_to_mean) order.push([idx,0,2]);
                        if (read_to_mean) order.push([idx,1,2]);
                    }
                    do_quiz(false /* repeat when wrong */);
                    break;

                case 2: // Meaning first
                    for (idx=0; idx<max; idx++) tmp_order.push(idx);
                    tmp_order.shuffle();
                    for (idx2=0; idx2<max; idx2++) {
                        idx = tmp_order[idx2];
                        if (aud_to_mean) order.push([idx,3,2]);
                        if (char_to_mean) order.push([idx,0,2]);
                        if (read_to_mean) order.push([idx,1,2]);
                        if (aud_to_read) order.push([idx,3,1]);
                        if (char_to_read) order.push([idx,0,1]);
                        if (mean_to_read) order.push([idx,2,1]);
                    }
                    do_quiz(false /* repeat when wrong */);
                    break;
            }
        }

        function do_quiz(requeue_when_wrong) {
            quiz_idx = (round > 1 ? -1 : 0);

            // Unhide the config dialog.
            var top = ssgrp.find('.btn-group').height() + 4;
            dialog.css('top',top).attr('class', itype);

            quiz_max = order.length-1;
            update_stats();
            update_question();
        }

        function item_meaning(item) {
            var arr = [];
            if (item.user_specific && item.user_specific.user_synonyms !== null)
                arr = item.user_specific.user_synonyms.map(function(v){return trim(v.replace('-',' '));});
            arr = arr.concat(item.meaning.split(',').map(function(v){return trim(v.replace('-',' '));}));
            return arr;
        }
        function item_reading(item, good_only) {
            var arr = [];
            if (item.kana) { // vocab
                arr = arr.concat(item.kana.split(',').map(function(v){return v.replace(/^\s*|\..*\s*$/g,'');}));
            } else if (item.important_reading) { // kanji
                if (good_only) {
                    arr = arr.concat(item[item.important_reading].split(',').map(function(v){return v.replace(/^\s*|\..*\s*$/g,'');}));
                } else {
                    if (item.onyomi) arr = arr.concat(item.onyomi.split(',').map(function(v){return v.replace(/^\s*|\..*\s*$/g,'');}));
                    if (item.kunyomi) arr = arr.concat(item.kunyomi.split(',').map(function(v){return v.replace(/^\s*|\..*\s*$/g,'');}));
                    if (item.nanori) arr = arr.concat(item.nanori.split(',').map(function(v){return v.replace(/^\s*|\..*\s*$/g,'');}));
                }
            }
            return arr;
        }

        function update_stats() {
            var remaining = quiz_max;
            if (quiz_idx > 0) remaining -= quiz_idx;
            if (!answered) remaining++;
            $('#ss_quiz .stats').html([round, remaining, correct, incorrect].join('<br>'));
        }

        function update_question() {
            if (quiz_idx === -1) {
                dialog.find('.round span').text('Round '+round);
                dialog.find('.qtype').removeClass('meaning').addClass('reading').html('Press [enter] to continue');
                dialog.addClass('round');
                $('body').on('keydown.ss_quiz keypress.ss_quiz', quiz_key);
                return;
            } else if (quiz_idx > quiz_max) {
                var percent = Math.floor(100*correct/Math.max(1,(correct+incorrect)));
                dialog.find('.summary .percent').text(percent+'%');
                if ((percent === 100) || (correct+incorrect === 0))
                    dialog.find('.summary .requiz').addClass('hidden');
                else
                    dialog.find('.summary .requiz').removeClass('hidden');
                dialog.find('.qtype').removeClass('meaning').addClass('reading').html('Press [enter] to continue');
                dialog.removeClass('round').addClass('summary');
                $('body').on('keydown.ss_quiz keypress.ss_quiz', quiz_key);
                return;
            }
            if (quiz_idx === 0) dialog.removeClass('round');

            var qinfo = order[quiz_idx];
            qtype = ['char','read','mean', 'aud'][qinfo[1]];
            atype = ['','reading','meaning'][qinfo[2]];
            var qtext;
            var answer = $('#ss_quiz .answer input');
            qlang = ''; alang = '';
            reset_answer();
            answered = false;

            all_answers = [];
            answer.val('');
            if (atype === 'reading') {
                if (!wanakana_isbound) {
                    wanakana.bind(answer[0]);
                    wanakana_isbound = true;
                }
            } else {
                if (wanakana_isbound) {
                    wanakana.unbind(answer[0]);
                    wanakana_isbound = false;
                }
            }
            item = items[qinfo[0]];
            if (itype === 'radicals') {
                qlang = 'ja';
                if (item.character !== null)
                    qtext = item.character;
                else
                    qtext = '<i class="radical-'+item.meaning+'"></i>';

                good_answers = item_meaning(item);
                all_answers = good_answers;
            } else {
                var mean_arr = item_meaning(item);
                var imp_read_arr = item_reading(item,true);
                var all_read_arr = item_reading(item);
                switch (qtype) {
                    case 'char':
                        qlang = 'ja';
                        qtext = item.character;
                        break;
                    case 'read':
                        qlang = 'ja';
                        qtext = toTitleCase(all_read_arr.join(', '));
                        break;
                    case 'mean':
                        qtext = toTitleCase(item_meaning(item).join(', '));
                        break;
                    case 'aud':
                        qtext = '<i class="icon-audio"></i>';
                        ichar = item.character;
                        break;
                }
                if (atype === 'reading') {
                    alang = 'ja';
                    good_answers = imp_read_arr;
                    all_answers = all_read_arr.concat(mean_arr);
                } else {
                    good_answers = mean_arr;
                    all_answers = mean_arr.concat(all_read_arr);
                }
            }

            var help_text = toTitleCase(good_answers.join(', '));
            if (qtype !== 'char') help_text += '<br>(<span lang="ja">'+item.character+'</span>)';
            dialog.find('.question').attr('data-type', qtype).attr('lang',qlang).html(qtext);
            dialog.find('.help').html(help_text).attr('lang',alang);
            var type_text = (itype==='radicals' ? 'radical' : itype) + ' <strong>'+atype+'</strong>';
            dialog.find('.qtype').removeClass('reading meaning').addClass(atype).html(type_text);
            if (itype==='vocabulary') {
                var play_audio_now = (qtype==='aud');
                var preload_audio = (play_audio_now || (settings.audio_mode && atype==='reading'));
                audio.set(item.character, item.level, preload_audio);
                if (play_audio_now) {
                    audio.play();
                }
            } else {
                audio.clear();
            }

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

        function prev_question(e) {
            if (quiz_idx === 0) return;
            quiz_idx--;
            if (e !== undefined) update_stats();
            if (quiz_idx === quiz_max) dialog.removeClass('round summary');
            update_question();
        }

        function next_question(e, prevent_exit) {
            quiz_idx++;
            if (quiz_idx > quiz_max) {
                if (!settings.quiz_repeat || force_summary) {
                    if (quiz_idx > quiz_max+1) {
                        if (!prevent_exit)
                            quiz.close();
                        else
                            quiz_idx--;
                        return;
                    }
                    if (e !== undefined) update_stats();
                } else if (settings.quiz_shuffle) {
                    dialog.removeClass('round summary');
                    round++;
                    answered = false;
                    update_stats();
                    create_quiz();
                }
            } else {
                if (e !== undefined) update_stats();
            }
            update_question();
        }

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

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

        function quiz_submit(e) {
            var input = $('#ss_quiz .answer input');

            // Handle keys for 'Round X' and 'Summary'
            if (quiz_idx === -1 || quiz_idx > quiz_max) {
                next_question();
                return;
            }

            if (input.hasClass('correct')) {
                set_help(false);
                reset_answer();
                next_question();
                return;
            } else if (input.hasClass('incorrect')) {
                set_help(false);
                reset_answer();
                return;
            }

            var answer = trim(input.val().toLowerCase());
            if (answer === '') return;

            var is_correct;
            if (settings.quiz_typo && alang != 'ja') {
                is_correct = (good_answers.filter(function(a){return (jw_distance(a,answer)>0.9);}).length > 0);
            } else {
                is_correct = (good_answers.indexOf(answer) >= 0);
            }
            if (is_correct) {
                if (!answered) {
                    correct++;
                    answered = true;
                    update_stats();
                }
                if (itype==='vocabulary' && settings.audio_mode && qtype!=='aud' && atype==='reading') {
                    audio.play();
                }
                if (settings.lightning_mode) return next_question();
                input.addClass('correct').prop('readonly',true);
                $('body').on('keydown.ss_quiz keypress.ss_quiz', quiz_key);
            } else if (all_answers.indexOf(answer) >= 0 ||
                       (alang === 'ja' ?
                        (!wanakana.isKana(answer) || all_answers.indexOf(wanakana.toRomaji(answer)) >= 0) :
                        (all_answers.indexOf(wanakana.toHiragana(answer)) >= 0)
                       )
                      ) {
                shake(input);
            } else {
                if (!answered) {
                    incorrect++;
                    answered = true;
                    update_stats();
                    requiz_order.push(order[quiz_idx]);
                }
                input.addClass('incorrect').prop('readonly',true);
                $('body').on('keydown.ss_quiz keypress.ss_quiz', quiz_key);
                dialog.find('.summary .errors').append(
                    '<li><span class="que"'+(qlang==='ja'?' lang="ja"':'')+' title="'+toTitleCase(dialog.find('.qtype').text())+'">'+
                    (qtype==='aud' ? ichar+' ' : '')+dialog.find('.question').html()+'</span> '+
                    '<i class="icon-long-arrow-right"></i> '+
                    '<span class="ans"'+(alang==='ja'?' lang="ja"':'')+' title="'+good_answers.join(', ')+'">'+
                    answer+' <i class="icon-remove-sign wrong"></i></span></li>'
                );
            }
        }

        function quiz_key(e) {
            $('body').off('.ss_quiz');
            var code;
            if (e.type==='keydown') {
                if (e.originalEvent.code === undefined) {
                    // Shim for Safari's lack of support for KeyboardEvent.code
                    switch (e.originalEvent.keyCode) {
                        case 8: code = 'Backspace'; break;
                        case 13: code = 'Enter'; break;
                        case 27: code = 'Escape'; break;
                        case 37: code = 'ArrowLeft'; break;
                        case 39: code = 'ArrowRight'; break;
                        case 65: code = 'KeyA'; break;
                        case 69: code = 'KeyE'; break;
                        case 72: code = 'KeyH'; break;
                        case 76: code = 'KeyL'; break;
                        case 79: code = 'KeyO'; break;
                        case 82: code = 'KeyR'; break;
                        case 83: code = 'KeyS'; break;
                        case 112: code = 'F1'; break;
                        default: code = 'Unknown'; break;
                    }
                } else {
                    code = e.originalEvent.code;
                }
            } else {
                code = String.fromCharCode(e.charCode);
            }

            if (code==='Enter') {
                quiz_submit(e);
            } else if (code==='Escape') { // Esc
                if (quiz_idx > quiz_max)
                    quiz_submit(e);
                else
                    goto_summary();
            } else if (code==='F1' || code==='?') { // F1 or ?
                toggle_help();
            } else if (code==='Backspace' && $('.answer input').prop('readonly')) { // Prevent backspace from navigating away from the page
                e.preventDefault();
                e.stopPropagation();
            } else if (e.ctrlKey || e.metaKey) {
                switch(code) {
                    case 'KeyA':
                        if (e.shiftKey) {
                            toggle_audio();
                        } else {
                            if (itype==='vocabulary') {
                                audio.play();
                            }
                        }
                        break;     // Audio
                    case 'KeyE': goto_summary(); break;     // End
                    case 'KeyH': toggle_help(); break;      // Help
                    case 'KeyL': toggle_lightning(); break; // Lightning
                    case 'KeyO': toggle_typo(); break;      // Typo ("oops")
                    case 'KeyR': toggle_repeat(); break;    // Repeat
                    case 'KeyS': if (e.shiftKey) quiz.refresh(); else toggle_shuffle(); break;   // Shuffle
                    case 'ArrowLeft' : prev_question(true /* update stats */, true /* prevent_exit */); break; // Previous question
                    case 'ArrowRight': next_question(true /* update stats */, true /* prevent_exit */); break; // Next question
                }
            } else {
                return;
            }
            e.preventDefault();
            e.stopPropagation();
        }

    })(quiz);

    //-------------------------------------------------------------------
    // Startup. Runs at document 'load' event.
    //-------------------------------------------------------------------
    function startup() {
        // Load settings.
        var s = localStorage.getItem('selfstudy_settings');
        if (s) {
            s = JSON.parse(s);
            if (s.compatible !== undefined && s.compatible == settings.compatible) {
                delete settings.configs;
                $.extend(true, settings, s);
            }
        }

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

        // Insert HTML
        $('section[id^="level-"]').prepend(html);

        populate_presets();

        // Install handlers
        $('.selfstudy button.enable').on('click', toggle_enable);
        $('.selfstudy button.quiz').on('click', quiz.open);
        $('.selfstudy button.shuffle').on('click', shuffle);
        $('.selfstudy select.config').on('change', config_change_event);
        $('.selfstudy button.config').on('click', configure);

        set_config(settings.selected_config);
        if (settings.enabled) {
            set_enable();
            shuffle();
        }
    }

    // Run startup() after window.onload event.
    if (document.readyState === 'complete')
        startup();
    else
        window.addEventListener("load", startup, false);

})(window.wkselfstudy);
2 Likes

@seanblue @rfindley So I hacked something together to allow for quizzing on leeches: [Unsupported] Leech training script (by copying and pasting tons of this script’s code - ahem).

FYI @riccyjay @zdennis because from skimming your posts it seemed like you were asking for something like this.

1 Like

Yes thats exactly what i wished for. Thank you very very much :slight_smile:

Can you explain a bit in detail how this works internal?

Do you quiz the people more on an item they get wrong in a quiz session?

@hitechbunny: That is interesting to me. Thanks for the notification!

Does anyone know, by the way, would it be difficult to get this script to run on the radical pages - where the list of kanji that use a certain radical is?

I think that could be enormously useful since a lot of leeches - in my case at least - have come about due to me taking mental shortcuts early on and memorizing kanji by only one or two of their radicals. Being able to run through a quick side by side review of all kanji with ‘axe’, for example, would hopefully help get a few things straight.

^^^ I’ll keep that in mind as I update the script for APIv2.

1 Like

Awesome script, you’re doing a fantastic job @rfindley! Can’t wait for the APIv2 update.

This is a brilliant script, rfindley, no matter what’s under the hood. I think I’m sold on the idea of drilling outside the SRS, especially if it’s going to be this convenient, and dare I say, fun. I’m going to try seanblue’s hardcoded modifications to see if I can drill just my apprentices per level. Meanwhile I look forward to seeing your improvements. Well done!

1 Like

seanblue, your script mod works great as well. Thanks!

I’d like to select from a list the kanji I want to self study. Either per level (just some of all kanji) or a fully self provided list…

Hey @rfindley! Thanks so much for putting this together, it’s incredible. Unfortunately, I’m having some difficulties getting audio to play in the listening quiz on Chrome on macOS 10.13.2.

From the javascript console:

4vocabulary?difficulty=PLEASANT:1

Failed to load wkstats Redirect from ‘wkstats’ to ‘https://www.wkstats.com/’ has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource. Origin ‘https://www.wanikani.com’ is therefore not allowed access.

Any advice? Thanks in advance

:point_right: [v2.1.0] – Updated audio map URLs to point to wkstats.com instead of idigtech.com.

@charwoni,
Your other error (re: bam.nr-data.net) is not related to the Self-Study script.

1 Like

This may end up in the new version of the script for APIv2. No promises, but it will certainly be a lot easier to implement.

3 Likes

Thank you! You’re awesome!

I swear, there is nothing that will make me more happy than this!

Do you take donations?

Heh… save it for when I actually get the script done :slight_smile:

I’m living off of savings at the moment while building a new business, so I have to ration my spare time while in development.

1 Like

Okay. Cool. If you’re ever in need of dinner let me know. :wink: