WaniKani Burn Manager

Mass Resurrect/Retire of Burn items on WaniKani

目前為 2016-04-16 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        WaniKani Burn Manager
// @namespace   rfindley
// @description Mass Resurrect/Retire of Burn items on WaniKani
// @version     1.0.0
// @include     https://www.wanikani.com/*
// @exclude     https://www.wanikani.com/lesson*
// @exclude     https://www.wanikani.com/review*
// @copyright   2016+, Robin Findley
// @license     MIT; http://opensource.org/licenses/MIT
// @run-at      document-end
// @grant       none
// ==/UserScript==

wkburnmgr = {};

(function(gobj) {

    var settings = {
        fetch: {
            levels_per: 5,
            retries: 3,
            timeout: 10000,
            simultaneous: 5
        },
        retire: {
            simultaneous: 5
        }
    };

    var mgr_added = false;
    var busy = false;
    var fetched = {rad:[], kan:[], voc:[]}, items = {rad:[], kan:[], voc:[]}, item_info={rad:{}, kan:{}, voc:{}};
    var srslvls = ['Apprentice 1','Apprentice 2','Apprentice 3','Apprentice 4','Guru 1','Guru 2','Master','Enlightened','Burned'];
    var user_level = 0, apikey='';

    gobj.fetched = fetched;
    gobj.items = items;
    gobj.item_info = item_info;

    //============================================================================
    // Asynchronous serialization and parallelization. (c) 2016 Robin Findley
    //----------------------------------------------------------------------------
    // Functions:
    //   do_parallel_n(n, [func], list);  // Do N items at a time.
    //   do_parallel([func], list);       // Do all items at same time.
    //   do_serial([func], list);         // Do 1 item one at a time.
    //
    // Parameters:
    //   n - Number of items to do at a time.
    //   func - If present, single function to run once for each item in list.
    //   list - If func is present, each element is args passed to func.
    //          If func is absent, each element is a function to call.
    //            You can use .bind() on functions to pass parameters (see bind()).
    //
    // Example:
    //   do_serial([
    //     initialize,
    //     do_parallel_n(2, fetch, [
    //       'http://www.example.com/page1',
    //       'http://www.example.com/page2',
    //       'http://www.example.com/page3'
    //     ]),
    //     parse_pages,
    //     do_parallel([
    //       setTimeout(console.log.bind(console,'Delay 1'), 500),
    //       setTimeout(console.log.bind(console,'Delay 2'),1000),
    //       setTimeout(console.log.bind(console,'Delay 3'),1500)
    //     ])
    //   ])
    //   .then(console.log.bind(console,'Done!'));
    //   .fail(console.log.bind(console,'Fail!'));
    //----------------------------------------------------------------------------
    function do_parallel_n(_n, _func, _list) {
        var self = this,
            promise = $.Deferred(),
            next = 0,
            simul = _n,
            completed = 0,
            mode = 0,
            result = [],
            list, func;

        if (typeof _func === 'function') {
            mode = 1;
            func = _func;
            list = _list;
        } else {
            mode = 0;
            list = _func;
        }
        if (simul <= 0) simul = list.length;
        if (list.length === 0) return promise.resolve();

        function do_one(value) {
            if (promise.state() === 'rejected') return;
            var n = next++;
            var p;

            if (mode === 0) {
                p = list[n](value);
            } else {
                var args = list[n];
                if (!Array.isArray(args))
                    args = [args];
                if (simul===1)
                    args.push(value);
                p = func.apply(self,args);
            }

            if ((typeof p !== 'object') || (typeof p.then !== 'function'))
                finish(p);
            else
                p.then(finish, fail);

            function finish(value) {
                result[n] = value;
                if (value === false) return promise.reject(result);
                if (next < list.length) do_one(value);
                if (++completed === list.length) promise.resolve(result);
            }

            function fail(value) {
                result[n] = value;
                promise.reject(result);
            }
        }

        for (var i = 0; i < simul && next < list.length; i++)
            do_one();

        return promise;
    }

    //----------------------------------------------------------------------------
    function do_parallel(_func, _list) {return do_parallel_n.call(this, -1, _func, _list);}
    function do_serial(_func, _list) {return do_parallel_n.call(this, 1, _func, _list);}

    //============================================================================
    // Fetch a document from the server.
    //----------------------------------------------------------------------------
    function ajax_retry(url, retries, timeout, callback, _options) {
        var promise = $.Deferred();
        retries = retries || 1;
        timeout = timeout || 120000;

        var options = {
            url: url,
            timeout: timeout
        };

        if (typeof callback === 'function') {
//            if (debug.dev_server)
                options.dataType = 'json';
//            else
//                options.dataType = 'jsonp';
        }
        $.extend(options, _options);

        function action() {
            $.ajax(options)
                .done(function(data, status){
                // Call optional callback.
                if (typeof callback === 'function')
                    callback(url, status, data);

                // Resolve or reject
                if (status === 'success')
                    promise.resolve(data);
                else
                    promise.reject();
            })
                .fail(function(xhr, status, error){
                // Try again?
                if (status === 'error' && --retries > 0)
                    return action();

                // Call optional callback, then reject.
                if (typeof callback === 'function')
                    callback(url, status, error, xhr);
                promise.reject();
            });
        }

        action();

        return promise;
    }

    //-------------------------------------------------------------------
    // Convert level-selection string to array.
    //-------------------------------------------------------------------
    function levels_from_string(str) {
      var levels = [];
      str = str.replace(' ','');
      $.each(str.split(','), function(idx, val){
        var dash = val.split('-');
        if (dash.length < 1 || dash.length > 2) return;
        var n1 = Number(dash[0]);
        var n2 = n1;
        if (dash.length === 2) n2 = Number(dash[1]);
        if (isNaN(n1) || isNaN(n2) || n1 < 1 || n2 < 1 || n1 > n2 || n1 > 200 || n2 > 200) return [];
        for (var n = n1; n <= n2; n++)
          levels[n] = 1;
      });
      return levels;
    }

    //-------------------------------------------------------------------
    // Convert level-selection array to string.
    //-------------------------------------------------------------------
    function levels_to_string(arr) {
      var levels = [];
      var n1 = 1, n2, cnt = arr.length;
      while (n1 <= cnt) {
        if (arr[n1] === 1) {
          n2 = n1+1;
          while (n2 <= cnt && arr[n2] ===1) n2++;
          n2--;
          if (n1 == n2)
            levels.push(n1);
          else
            levels.push(n1+'-'+n2);
          n1 = n2;
        }
        n1++;
      }
      return levels.join(',');
    }

    //-------------------------------------------------------------------
    // Add a <style> section to the document.
    //-------------------------------------------------------------------
    function addStyle(aCss) {
        var head, style;
        head = document.getElementsByTagName('head')[0];
        if (head) {
            style = document.createElement('style');
            style.setAttribute('type', 'text/css');
            style.textContent = aCss;
            head.appendChild(style);
            return style;
        }
        return null;
    }

    //-------------------------------------------------------------------
    // Display the Burn Manager object.
    //-------------------------------------------------------------------
    function add_mgr() {
        var html =
            '<div id="burn_mgr"><div id="burn_mgr_box" class="container">'+
            '<h3 class="small-caps invert">Burn Manager <span id="burn_mgr_instr" href="#">[ Instructions ]</span></h3>'+

            '<form accept-charset="UTF-8" action="#" class="form-horizontal"><fieldset class="additional-info">'+

            // Instructions
            '  <div class="instructions">'+
            '    <div class="header small-caps invert">Instructions</div>'+
            '    <div class="content">'+
            '      <p>Enter your Resurrect/Retire criteria below, then click <span class="btn">Preview</span>.<br>A preview window will open, showing burn items matching the Level and Type criteria.<br>'+
                     'You can change your criteria at any time, then click <span class="btn">Preview</span> again to update your settings... but any <b>manually toggled changes will be lost</b>.</p>'+
            '      <p class="nogap">In the preview window:</p>'+
            '      <ul>'+
            '        <li><b>Hover</b> over an item to see <b>item details</b>.</li>'+
            '        <li><b>Click</b> an item to <b>toggle</b> its desired state between <b>Resurrect</b> and <b>Retired</b>.</li>'+
            '      </ul>'+
            '      <p>After you have adjusted all items to their desired state, click <span class="btn">Execute</span> to begin changing you item statuses<br>'+
                     'While executing, please allow the progress bar to reach 100% before navigating to another page, otherwise some items will not be Resurrected or Retired.</p>'+
            '      <span class="rad">十</span><span class="kan">本</span><span class="voc">本当</span> = Will be Resurrected<br>'+
            '      <span class="rad inactive">十</span><span class="kan inactive">本</span><span class="voc inactive">本当</span> = Will be Retired'+
            '    </div>'+
            '  </div>'+

            // Settings
            '  <div class="control-group">'+
            '    <label class="control-label" for="burn_mgr_levels">Level Selection:</label>'+
            '    <div class="controls">'+
            '      <input id="burn_mgr_levels" type="text" autocomplete="off" class="span6" max_length=255 name="burn_mgr[levels]" placeholder="Levels to resurrect or retire (e.g. &quot;1-3,5&quot;)" value>'+
            '    </div>'+
            '  </div>'+
            '  <div class="control-group">'+
            '    <label class="control-label">Item types:</label>'+
            '    <div id="burn_mgr_types" class="controls">'+
            '      <label class="checkbox inline"><input id="burn_mgr_rad" name="burn_mgr[rad]" type="checkbox" value="1" checked="checked">Radicals</label>'+
            '      <label class="checkbox inline"><input id="burn_mgr_kan" name="burn_mgr[kan]" type="checkbox" value="1" checked="checked">Kanji</label>'+
            '      <label class="checkbox inline"><input id="burn_mgr_voc" name="burn_mgr[voc]" type="checkbox" value="1" checked="checked">Vocab</label>'+
            '    </div>'+
            '  </div>'+
            '  <div class="control-group">'+
            '    <label class="control-label" for="burn_mgr_initial">Action / Initial State:</label>'+
            '    <div id="burn_mgr_initial" class="controls">'+
            '      <label class="radio inline"><input id="burn_mgr_initial_current" name="burn_mgr[initial]" type="radio" value="0" checked="checked">No change / Current state</label>'+
            '      <label class="radio inline"><input id="burn_mgr_initial_resurrect" name="burn_mgr[initial]" type="radio" value="1">Resurrect All</label>'+
            '      <label class="radio inline"><input id="burn_mgr_initial_retire" name="burn_mgr[initial]" type="radio" value="2">Retire All</label>'+
            '    </div>'+
            '  </div>'+
            '  <div class="control-group">'+
            '    <div id="burn_mgr_btns" class="controls">'+
            '      <a id="burn_mgr_preview" href="#burn_mgr_preview" class="btn btn-mini">Preview</a>'+
            '      <a id="burn_mgr_execute" href="#burn_mgr_execute" class="btn btn-mini">Execute</a>'+
            '      <a id="burn_mgr_close" href="#burn_mgr_close" class="btn btn-mini">Close</a>'+
            '    </div>'+
            '  </div>'+

            // Preview
            '  <div class="status"><div class="message controls"></div></div>'+
            '  <div class="preview"></div>'+
            '  <div id="burn_mgr_item_info" class="hidden"></div>'+

            '</fieldset>'+
            '</form>'+
            '<hr>'+
            '</div></div>';

        var css =
            '#burn_mgr {display:none;}'+

            '#burn_mgr_instr {margin-left:20px; font-size:0.8em; opacity:0.8; cursor:pointer;}'+
            '#burn_mgr .instructions {display:none;}'+
            '#burn_mgr .instructions .content {padding:5px;}'+
            '#burn_mgr .instructions p {font-size:13px; line-height:17px; margin-bottom:1.2em;}'+
            '#burn_mgr .instructions p.nogap {margin-bottom:0;}'+
            '#burn_mgr .instructions ul {margin-left:16px; margin-bottom:1.2em;}'+
            '#burn_mgr .instructions li {font-size:13px; line-height:17px;}'+
            '#burn_mgr .instructions span {cursor:default;}'+
            '#burn_mgr .instructions .btn {color:#000; padding:0px 3px 2px 3px;}'+
            '#burn_mgr .noselect {-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;}'+

            '#burn_mgr h3 {'+
            '  margin-top:10px; margin-bottom:0px; padding:0 30px; border-radius: 5px 5px 0 0;'+
            '  background-color: #fbc042;'+
            '  background-image: -moz-linear-gradient(-45deg, #fbc550, #faac05);'+
            '  background-image: -webkit-linear-gradient(-45deg, #fbc550, #faac05);'+
            '  background-image: -o-linear-gradient(-45deg, #fbc550, #faac05);'+
            '  background-image: linear-gradient(-45deg, #fbc550, #faac05);'+
            '}'+

            '#burn_mgr form {border-radius:0 0 5px 5px; margin-bottom:10px;}'+
            '#burn_mgr #burn_mgr_box fieldset {border-radius:0 0 5px 5px; margin-bottom:0px; padding:10px;}'+
            '#burn_mgr .control-group {margin-bottom:10px;}'+
            '#burn_mgr .controls .inline {padding-right:10px;}'+
            '#burn_mgr .controls .inline input {margin-left:-15px;}'+
            '#burn_mgr_btns .btn {width:50px; margin-right:10px;}'+

            '#burn_mgr .status {display:none;}'+
            '#burn_mgr .status .message {display:inline-block; background-color:#ffc; padding:2px 10px; font-weight:bold; border:1px solid #999; min-width:196px;}'+

            '#burn_mgr .preview {display:none;}'+
            '#burn_mgr .header {padding:0px 3px; line-height:1.2em; margin:0px;}'+
            '#burn_mgr .preview .header .count {text-transform:none; margin-left:10px;}'+
            '#burn_mgr .content {padding:0px 2px 2px 2px; border:1px solid #999; border-top:0px; background-color:#fff; margin-bottom:10px; position:relative;}'+
            '#burn_mgr .content span {'+
            '  color:#fff;'+
            '  font-size:13px;'+
            '  line-height:13px;'+
            '  margin:0px 1px;'+
            '  padding:2px 3px 3px 2px;'+
            '  border-radius:4px;'+
            '  box-shadow:0 -2px 0 rgba(0,0,0,0.2) inset;'+
            '  display:inline-block;'+
            '}'+
            '#burn_mgr .rad {background-color:#0096e7; background-image:linear-gradient(to bottom, #0af, #0093dd);}'+
            '#burn_mgr .kan {background-color:#ee00a1; background-image:linear-gradient(to bottom, #f0a, #dd0093);}'+
            '#burn_mgr .voc {background-color:#9800e8; background-image:linear-gradient(to bottom, #a0f, #9300dd);}'+
            '#burn_mgr .rad.inactive {background-color:#c3e3f3; background-image:linear-gradient(to bottom, #d4ebf7, #c3e3f3);}'+
            '#burn_mgr .kan.inactive {background-color:#f3c3e3; background-image:linear-gradient(to bottom, #f7d4eb, #f3c3e3);}'+
            '#burn_mgr .voc.inactive {background-color:#e3c3f3; background-image:linear-gradient(to bottom, #ebd4f7, #e3c3f3);}'+

            '#burn_mgr .preview .content span {cursor:pointer;}'+

            '#burn_mgr_item_info {'+
            '  position: absolute;'+
            '  padding:8px;'+
            '  color: #eeeeee;'+
            '  background-color:rgba(0,0,0,0.8);'+
            '  border-radius:8px;'+
            '  font-family:"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif;'+
            '  font-weight: bold;'+
            '  z-index:3;'+
            '}'+
            '#burn_mgr_item_info .item {font-size:2em; line-height:1.2em;}'+
            '#burn_mgr_item_info .item img {height:1em; width:1em; vertical-align:bottom;}'+
            '#burn_mgr_item_info>div {padding:0 8px; background-color:#333333;}'+

            '#burn_mgr hr {border-top-color:#bbb; margin-top:0px; margin-bottom:0px;}';

        addStyle(css);
        $(html).insertAfter($('.navbar'));

        // Add event handlers
        $('#burn_mgr_preview').on('click', on_preview);
        $('#burn_mgr_execute').on('click', on_execute);
        $('#burn_mgr_close').on('click', on_close);
        $('#burn_mgr_instr').on('click', on_instructions);

        mgr_added = true;
    }

    //-------------------------------------------------------------------
    // Toggle the Burn Manager open/closed.
    //-------------------------------------------------------------------
    function toggle_mgr(e) {
        if (e !== undefined) e.preventDefault();

        // Add the manager if not already.
        if (!mgr_added) add_mgr();

        $('#burn_mgr').slideToggle();
        $('html, body').animate({scrollTop:0},800);
    }

    //-------------------------------------------------------------------
    // Fetch API key from account page.
    //-------------------------------------------------------------------
    function get_apikey() {
        var promise = $.Deferred();

        apikey = localStorage.getItem('apiKey');
        if (typeof apikey === 'string' && apikey.length == 32)
            return promise.resolve();

        $('#burn_mgr .status .message').html('Fetching API key...');
        $('#burn_mgr .status').slideDown();
        ajax_retry('/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.
            apikey = page.find('#api-button').parent().find('input').attr('value');
            if (typeof apikey !== 'string' || apikey.length !== 32)  {return reject();}

            localStorage.setItem('apiKey', apikey);
            promise.resolve();

        }).fail(function(result) {

            // --[ FAIL ]-------------------------
            $('#burn_mgr .status .message').html('Fetching API key...  FAILED!');
            promise.reject(new Error('Failed to fetch API key!'));

        });

        return promise;
    }

    //-------------------------------------------------------------------
    // Event handler for item click.
    //-------------------------------------------------------------------
    function item_click_event(e) {
        $(e.currentTarget).toggleClass('inactive');
    }

    //-------------------------------------------------------------------
    // Event handler for item hover info.
    //-------------------------------------------------------------------
    function item_info_event(e) {
        var hinfo = $('#burn_mgr_item_info');
        var target = $(e.currentTarget);
        switch (e.type) {
            //-----------------------------
            case 'mouseenter':
                var itype = target.data('type');
                var ref = target.data('ref');
                var item = item_info[itype][ref];
                var status = (can_resurrect(item)===true ? 'Retired' : 'Resurrected');
                var str = '<div class="'+itype+'">';
                switch (itype) {
                    case 'rad':
                        str += '<span class="item">Item: <span lang="jp">';
                        if (item.character !== null)
                            str += item.character+'</span></span><br />';
                        else
                            str += '<img src="'+item.image+'" /></span></span><br />';
                        str += 'Meaning: '+toTitleCase(item.meaning)+'<br />';
                        break;
                    case 'kan':
                        str += '<span class="item">Item: <span lang="jp">'+item.character+'</span></span><br />';
                        str += toTitleCase(item.important_reading)+': <span lang="jp">'+item[item.important_reading]+'</span><br />';
                        str += 'Meaning: '+toTitleCase(item.meaning)+'<br />';
                        if (item.user_specific.user_synonyms !== null && item.user_specific.user_synonyms.length > 0)
                            str += 'Synonyms: '+toTitleCase(item.user_specific.user_synonyms.join(', '))+'<br />';
                        break;
                    case 'voc':
                        str += '<span class="item">Item: <span lang="jp">'+item.character+'</span></span><br />';
                        str += 'Reading: <span lang="jp">'+item.kana+'</span><br />';
                        str += 'Meaning: '+toTitleCase(item.meaning)+'<br />';
                        if (item.user_specific.user_synonyms !== null && item.user_specific.user_synonyms.length > 0)
                            str += 'Synonyms: '+toTitleCase(item.user_specific.user_synonyms.join(', '))+'<br />';
                        break;
                }
                str += 'Level: '+item.level+'<br />';
                str += 'SRS Level: '+srslvls[item.user_specific.srs_numeric-1]+'<br />';
                str += 'Currently: '+status+'<br />';
                str += '</div>';
                hinfo.html(str);
                hinfo.css('left', target.offset().left - target.position().left);
                hinfo.css('top', target.offset().top + target.outerHeight() + 3);
                hinfo.removeClass('hidden');
                break;

            //-----------------------------
            case 'mouseleave':
                hinfo.addClass('hidden');
                break;
        }
    }

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

    //-------------------------------------------------------------------
    // Read the user's "initial state" setting.
    //-------------------------------------------------------------------
    function read_initial_state() {
        return Number($('#burn_mgr_initial input:checked').val());
    }

    //-------------------------------------------------------------------
    // Run when user clicks 'Preview' button
    //-------------------------------------------------------------------
    function on_preview(e, refresh) {
        if (refresh !== true) e.preventDefault();
        if (busy) return;
        busy = true;

        // Read the user's level selection criteria
        var levels = levels_from_string($('#burn_mgr_levels').val());

        // Convert the input field to normalized form
        $('#burn_mgr_levels').val(levels_to_string(levels));

        // Fetch data
        load_data(levels)
        .then(function(){
            // Hide the "Loading" message.
            busy = false;
            $('#burn_mgr .status').slideUp();

            var html = '';
            var itypes = ['rad', 'kan', 'voc'];
            var state = read_initial_state();
            if (refresh === true) state = 0;
            var get_initial = [
                /* 0 */ function(item) {return (can_retire(item)===true);}, // Show current item state.
                /* 1 */ function(item) {return true;},                      // Mark all items for resurrection.
                /* 2 */ function(item) {return false;},                     // Mark all items for retirement.
            ][state];

            for (var level = 1; level <= user_level; level++) {
                if (levels[level] !== 1) continue;
                var active = {rad:0, kan:0, voc:0};
                var total = {rad:0, kan:0, voc:0};
                var item_html = '';
                $.each(itypes, function(idx, itype){
                    // Skip item types that aren't checked.
                    var subact=0, subtot=0;
                    var list = items[itype][level];
                    if (!$('#burn_mgr_'+itype).is(':checked')) return;
                    if (list === undefined) return;
                    $.each(list,function(idx,item){
                        var text, ref, state;
                        if (itype === 'rad') {
                            if (item.image !== null)
                                text = '<i class="radical-'+item.meaning+'"></i>';
                            else
                                text = item.character;
                        } else {
                            text = item.character;
                        }
                        ref = item.ref;
                        if (get_initial(item)) {
                            state = '';
                            subact++;
                        } else {
                            state = ' inactive';
                        }
                        subtot++;
                        item_html += '<span class="'+itype+state+'" data-type="'+itype+'" data-ref="'+ref+'">'+text+'</span>';
                    });
                    active[itype] += subact;
                    total[itype] += subtot;
                });
                html +=
                    '<div class="header small-caps invert">Level '+level+
//                    '  <span class="count">Resurrected: [ R: '+active.rad+'/'+total.rad+', K: '+active.kan+'/'+total.kan+', V: '+active.voc+'/'+total.voc+' ]</span>'+
                    '</div>'+
                    '<div class="content level noselect">'+
                    item_html+
                    '</div>';
            }
            $('#burn_mgr .preview').html(html).slideDown();
            $('#burn_mgr .preview .content.level')
                .on('mouseenter', 'span', item_info_event)
                .on('mouseleave', item_info_event)
                .on('click', 'span', item_click_event);
        });
    }

    //-------------------------------------------------------------------
    // Run when user clicks 'Execute' button
    //-------------------------------------------------------------------
    function on_execute(e) {
        e.preventDefault();
        if (busy) return;
        busy = true;

        var status = $('#burn_mgr .status'), message = $('#burn_mgr .status .message');

        // Read the user's level selection criteria
        var levels = levels_from_string($('#burn_mgr_levels').val());

        // Convert the input field to normalized form
        $('#burn_mgr_levels').val(levels_to_string(levels));

        // Fetch data
        load_data(levels)
        .then(function(){
            var task_list = [];
            var xlat = {rad:'radical',kan:'kanji',voc:'vocabulary'};
            var use_preview = $('#burn_mgr .preview').is(':visible');
            if (use_preview) {
                $('#burn_mgr .preview .content span').each(function(idx,elem){
                    elem = $(elem);
                    var ref = elem.data('ref');
                    var itype = elem.data('type');
                    var item = item_info[itype][ref];
                    var current = can_resurrect(item);
                    var want = elem.hasClass('inactive');
                    if (current != want)
                        task_list.push({url:'/retired/'+xlat[itype]+'/'+ref+'?'+(want?'retire':'resurrect')+'=true',item:item});
                });
            } else {
                // Don't use Preview information.
                var state = read_initial_state();
                if (state !== 0) {
                    $.each(['rad','kan','voc'], function(idx, itype){
                        if (!$('#burn_mgr_'+itype).is(':checked')) return;
                        $.each(item_info[itype], function(idx, item){
                            var ref = item.ref;
                            var current = can_resurrect(item);
                            var want = (state===2);
                            if (current != want)
                                task_list.push({url:'/retired/'+xlat[itype]+'/'+ref+'?'+(want?'retire':'resurrect')+'=true',item:item});
                        });
                    });
                }
            }

            var tot = task_list.length;
            var cnt = 0;

            function task_done(item, url, status, text) {
                if (status !== 'success') return;

                message.html('Working... ('+(++cnt)+' of '+tot+')');
                var lines = text.split('\n');
                var idx;
                for (idx = lines.length-1; idx >= 0; idx--) {
                    var line = lines[idx];
                    if (line.match(/var progression/) === null) continue;
                    var json = JSON.parse(line.match(/(\{.*\})/g)).requested_information;
                    item.user_specific.burned = json.burned;
                    item.user_specific.burned_date = json.burned_date;
                    item.user_specific.available_date = json.available_date;
                    item.user_specific.srs = json.srs_level;
                    break;
                }
            }

            function retire(task) {
                return ajax_retry(task.url, settings.retire.retries, settings.retire.timeout, task_done.bind(null,task.item), {
                    type:'POST',
                    data:'_method=put',
                    dataType:'text'
                }
                );
            }

            function finish() {
                message.html('Done! ('+cnt+' of '+tot+')');
                busy = false;
                on_preview(null, true /* refresh */);
            }

            message.html('Executing 0 / '+tot);
            status.slideDown();
            do_serial([
                do_parallel_n.bind(null,settings.retire.simultaneous, retire, task_list),
                finish
            ]);
        });
    }

    //-------------------------------------------------------------------
    // Run when user clicks 'Close' button
    //-------------------------------------------------------------------
    function on_close(e) {
        e.preventDefault();
        $('#burn_mgr').slideUp();
    }

    //-------------------------------------------------------------------
    // Run when user clicks 'Instructions'
    //-------------------------------------------------------------------
    function on_instructions(e) {
        e.preventDefault();
        $('#burn_mgr .instructions').slideToggle();
    }

    //-------------------------------------------------------------------
    // Return 'true' if item can be retired.
    //-------------------------------------------------------------------
    function can_retire(item){
        if (item.user_specific === null) return false;
        if (item.user_specific.srs !== 'burned' && item.user_specific.burned_date !== 0) return true;
        return false;
    }

    //-------------------------------------------------------------------
    // Return 'true' if item can be resurrected.
    //-------------------------------------------------------------------
    function can_resurrect(item){
        if (item.user_specific === null) return false;
        if (item.user_specific.srs === 'burned') return true;
        return false;
    }

    //-------------------------------------------------------------------
    // Return 'true' if item has been burned.
    //-------------------------------------------------------------------
    function has_burned(item){
        if (item.user_specific === null) return false;
        if (item.user_specific.srs === 'burned') return true;
        if (item.user_specific.burned_date !== 0) return true;
        return false;
    }

    //-------------------------------------------------------------------
    // Fetch API data for any requested levels that aren't yet fetched
    //-------------------------------------------------------------------
    function load_data(levels) {
        var criteria = [];
        var status = $('#burn_mgr .status'), message = $('#burn_mgr .status .message');
        var percent, total, done;

        var itypes = ['radicals', 'kanji', 'vocabulary'];
        $.each(itypes, function(idx, itype){
            // Skip item types that aren't checked.
            var itype3 = itype.slice(0,3);
            if (!$('#burn_mgr_'+itype3).is(':checked')) return;
            var needed = {itype:itype, levels:[]};
            for (level = 1; level <= user_level; level++) {
                if (levels[level] !== 1 || fetched[itype3][level] === 1) continue;
                needed.levels.push(level);
                if (itype3 !== 'rad' && needed.levels.length === settings.fetch.levels_per) {
                    criteria.push([needed]);
                    needed = {itype:itype, levels:[]};
                }
            }
            if (needed.levels.length > 0)
                criteria.push([needed]);
        });

        function received(criteria, url, status, json) {
            if (status !== 'success') return;

            done++;
            percent.html(Math.floor(done/total*100)+'%');

            // Mark which levels we've fetched, so we don't re-fetch on later queries.
            var itype = criteria.itype.slice(0,3);
            $.each(criteria.levels, function(idx, level){
                fetched[itype][level] = 1;
            });

            $.each(json.requested_information, function(idx, item){
                if (!has_burned(item)) return;
                if (itype === 'rad')
                    item.ref = item.meaning;
                else
                    item.ref = item.character;
                var level_items = items[itype][item.level];
                if (level_items === undefined) {
                    level_items = [];
                    items[itype][item.level] = level_items;
                }
                level_items.push(item);
                item_info[itype][item.ref] = item;
            });
        }

        function fetch(criteria) {
            var url = 'https://www.wanikani.com/api/user/'+apikey+'/'+criteria.itype+'/'+criteria.levels.join(',');
            return ajax_retry(url, settings.fetch.retries, settings.fetch.timeout, received.bind(null,criteria));
        }

        total = criteria.length;
        done = 0;
        if (total === 0) return $.Deferred().resolve();

        message.html('Loading item data: <span class="percent">0%</span>');
        status.slideDown();
        percent = $('#burn_mgr .status .percent');

        return do_serial([
            get_apikey,
            do_parallel_n.bind(null,settings.fetch.simultaneous, fetch, criteria)
        ]);
    }

    //-------------------------------------------------------------------
    // Startup. Runs at document 'load' event.
    //-------------------------------------------------------------------
    function startup() {
        // Make sure we have a top-menu, req'd for insertion.
        if ($('.navbar').length === 0) return;

        user_level = Number($('.levels span:nth(0)').text());

        // Insert a menu link in the drop-down menu.
        $('<li><a href="#burnmgr">Burn Manager</a></li>')
        .insertBefore($('.account .dropdown-menu .nav-header:eq(1)'))
        .on('click', toggle_mgr);
    }

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

})(wkburnmgr);