Greasy Fork is available in English.

Wanikani Burn Manager

Mass Resurrect/Retire of Burn items on WaniKani

Version au 22/02/2018. Voir la dernière version.

// ==UserScript==
// @name        Wanikani Burn Manager
// @namespace   rfindley
// @description Mass Resurrect/Retire of Burn items on WaniKani
// @version     2.0.1
// @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==

window.burnmgr = {};

(function(gobj) {

    if (!window.wkof) {
        alert('Burn Manager script requires Wanikani Open Framework.\nYou will now be forwarded to installation instructions.');
        window.location.href = 'https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549';
        return;
    }

    wkof.include('ItemData,Menu');
    wkof.ready('ItemData,Menu').then(startup);

    var mgr_added = false, busy = false, items, items_by_id;

    function startup() {
        wkof.Menu.insert_script_link({
            name: 'burnmgr',
            submenu: 'Open',
            title: 'Burn Manager',
            on_click: open_burnmgr
        });
    }

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

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

    var srslvls = ['Apprentice 1','Apprentice 2','Apprentice 3','Apprentice 4','Guru 1','Guru 2','Master','Enlightened','Burned'];

    //-------------------------------------------------------------------
    // 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 > img {height:0.9em;}'+
            '#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;}';

        $('head').append('<style type="text/css">'+css+'</style>');
        $(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;
    }

    //-------------------------------------------------------------------
    // 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 = items_by_id[ref];
                var status = (can_resurrect(item)===true ? 'Retired' : 'Resurrected');
                var str = '<div class="'+itype+'">';
                var readings, reading_str, important_reading, meanings, meaning_str, synonyms, synonym_str;
                switch (itype) {
                    case 'rad':
                        meanings = item.data.meanings.filter(primary);
                        meaning_str = meanings.map(meaning).join(', ');
                        str += '<span class="item">Item: <span lang="ja">';
                        if (item.data.characters !== null)
                            str += item.data.characters+'</span></span><br />';
                        else
                            str += '<img src="'+item.data.character_images[0].url+'" /></span></span><br />';
                        str += 'Meaning: '+toTitleCase(meaning_str)+'<br />';
                        if (item.study_materials && item.study_materials.meaning_synonyms.length > 0)
                            str += 'Synonyms: '+toTitleCase(item.study_materials.meaning_synonyms.join(', '))+'<br />';
                        break;
                    case 'kan':
                        readings = item.data.readings.filter(primary);
                        important_reading = readings[0].type;
                        reading_str = readings.map(reading).join(', ');
                        meanings = item.data.meanings.filter(primary);
                        meaning_str = meanings.map(meaning).join(', ');
                        str += '<span class="item">Item: <span lang="ja">'+item.data.characters+'</span></span><br />';
                        str += toTitleCase(important_reading)+': <span lang="ja">'+reading_str+'</span><br />';
                        str += 'Meaning: '+toTitleCase(meaning_str)+'<br />';
                        if (item.study_materials && item.study_materials.meaning_synonyms.length > 0)
                            str += 'Synonyms: '+toTitleCase(item.study_materials.meaning_synonyms.join(', '))+'<br />';
                        break;
                    case 'voc':
                        readings = item.data.readings.filter(primary);
                        reading_str = readings.map(reading).join(', ');
                        meanings = item.data.meanings.filter(primary);
                        meaning_str = meanings.map(meaning).join(', ');
                        str += '<span class="item">Item: <span lang="ja">'+item.data.characters+'</span></span><br />';
                        str += 'Reading: <span lang="ja">'+reading_str+'</span><br />';
                        str += 'Meaning: '+toTitleCase(meaning_str)+'<br />';
                        if (item.study_materials && item.study_materials.meaning_synonyms.length > 0)
                            str += 'Synonyms: '+toTitleCase(item.study_materials.meaning_synonyms.join(', '))+'<br />';
                        break;
                }
                str += 'Level: '+item.data.level+'<br />';
                str += 'SRS Level: '+srslvls[item.assignments.srs_stage-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;
        }
    }

    //-------------------------------------------------------------------
    // Filters and maps
    //-------------------------------------------------------------------
    function primary(info) {return info.primary;}
    function meaning(info) {return info.meaning;}
    function reading(info) {return info.reading;}

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

        var preview_is_open = $('#burn_mgr .preview').is(':visible');
        if (preview_is_open) {
            $('#burn_mgr .preview').html('').slideUp();
            busy = true;
            fetch_items(true /* force_update */).then(populate_data.bind(null, refresh));
        } else {
            busy = true;
            fetch_items(true /* force_update */).then(populate_data.bind(null, refresh));
        }
    }

    //-------------------------------------------------------------------
    // Fetch the requested items
    //-------------------------------------------------------------------
    function fetch_items(force_update) {
        var levels = $('#burn_mgr_levels').val();
        if (levels === '') levels = '*';

        var item_type = [];
        if ($('#burn_mgr_rad').attr('checked') === 'checked') item_type.push('rad');
        if ($('#burn_mgr_kan').attr('checked') === 'checked') item_type.push('kan');
        if ($('#burn_mgr_voc').attr('checked') === 'checked') item_type.push('voc');

        $('#burn_mgr .status .message').html('Fetching data...');
        $('#burn_mgr .status').slideDown();

        return wkof.ItemData.get_items({
            wk_items: {
                options: {subjects: true, assignments: true, study_materials: true},
                filters: {
                    have_burned: true,
                    level: levels,
                    item_type: item_type
                }
            }
        }, {force_update: force_update});
    }

    //-------------------------------------------------------------------
    // Populate the item data on-screen.
    //-------------------------------------------------------------------
    function populate_data(refresh, data) {
        // Hide the "Loading" message.
        busy = false;
        $('#burn_mgr .status').slideUp();
        items = data;
        items_by_id = wkof.ItemData.get_index(items, 'subject_id');
        window.items = items;

        var html = '';
        var itypes = ['radical', 'kanji', 'vocabulary'];
        var state = read_initial_state();
        if (refresh === true) state = 0;
        var get_initial = [
            /* 0 */ function(item) {return can_retire(item);}, // 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];

        var items_by_level = wkof.ItemData.get_index(items, 'level');
        for (var level = 1; level <= wkof.user.level; level++) {
            var level_items = items_by_level[level];
            if (!level_items) continue;
            var items_by_type = wkof.ItemData.get_index(level_items, 'item_type');
            var item_html = '';
            $.each(itypes, function(idx, itype){
                // Skip item types that aren't checked.
                var itype3 = itype.slice(0,3);
                var list = items_by_type[itype];
                if (!$('#burn_mgr_'+itype3).is(':checked')) return;
                if (list === undefined) return;
                $.each(list,function(idx,item){
                    var text, ref, state;
                    text = item.data.slug;
                    if (itype3 === 'rad') {
                        if (item.data.character_images.length > 0)
                            text = '<img src="'+item.data.character_images[0].url+'">';
                        else
                            text = item.data.characters;
                    } else {
                        text = item.data.characters;
                    }
                    if (get_initial(item))
                        state = '';
                    else
                        state = ' inactive';
                    item_html += '<span class="'+itype3+state+'" data-type="'+itype3+'" data-ref="'+item.id+'">'+text+'</span>';
                });
            });
            html +=
                '<div class="header small-caps invert">Level '+level+
                '</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');
        var use_preview = $('#burn_mgr .preview').is(':visible');
        var task_list = [];

        var auth_token = encodeURIComponent($('[name="csrf-token"]').attr('content'));

        if (use_preview) {
            $('#burn_mgr .preview .content span').each(function(idx,elem){
                elem = $(elem);
                var ref = elem.data('ref');
                var item = items_by_id[ref];
                var current = can_resurrect(item);
                var want = elem.hasClass('inactive');
                if (current != want)
                    task_list.push({url:'/assignments/'+ref+'/'+(want?'burn':'resurrect'),item:item});
            });
            start_execute();
        } else {
            // Don't use Preview information.
            fetch_items(true /* force_update */).then(function(items){
                var state = read_initial_state();
                if (state === 0) return;
                var want = (state===2);
                $.each(items, function(idx, item){
                    var ref = item.id;
                    var current = can_resurrect(item);
                    if (current != want)
                        task_list.push({url:'/assignments/'+ref+'/'+(want?'burn':'resurrect'),item:item});
                });
                start_execute();
            });
        }

        var cnt, tot;

        function start_execute() {
            tot = task_list.length;
            cnt = 0;
            message.html('Executing 0 / '+tot);
            status.slideDown();

            var simultaneous = Math.min(5, tot);
            for (cnt=0; cnt<simultaneous; cnt++) {
                retire(task_list[cnt]).then(next, next);
            }

            function next(result) {
                if (cnt < tot) {
                    message.html('Working... ('+cnt+' of '+tot+')');
                    retire(task_list[cnt++]).then(next, next);
                } else {
                    message.html('Done! ('+cnt+' of '+tot+')');
                    busy = false;
                    on_preview(null, true /* refresh */);
                }
            }
        }

        function retire(task) {
            return new Promise(function(resolve, reject){
                $.ajax(task.url, {
                    type:'POST',
                    data:'_method=put&authenticity_token='+auth_token,
                    dataType:'text'
                }).done(function(){
                    resolve({status:'success', task:task});
                }).fail(function(){
                    reject({status:'fail', task:task});
                });
            });
        }
    }

    //-------------------------------------------------------------------
    // Run when user clicks 'Close' button
    //-------------------------------------------------------------------
    function on_close(e) {
        e.preventDefault();
        var preview_is_open = $('#burn_mgr .preview').is(':visible');
        if (preview_is_open) $('#burn_mgr .preview').html('').slideUp();
        $('#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){
        return (item.assignments.srs_stage_name !== 'Burned');
    }

    //-------------------------------------------------------------------
    // Return 'true' if item can be resurrected.
    //-------------------------------------------------------------------
    function can_resurrect(item){
        return (item.assignments.srs_stage_name === 'Burned');
    }

})(window.burnmgr);