Wanikani Dashboard Progress Plus

Display detailed level progress.

目前為 2021-12-16 提交的版本,檢視 最新版本

// ==UserScript==
// @name        Wanikani Dashboard Progress Plus
// @namespace   rfindley
// @description Display detailed level progress.
// @version     3.0.9
// @include     /^https://(www|preview).wanikani.com/(dashboard)?$/
// @copyright   2018+, Robin Findley
// @license     MIT; http://opensource.org/licenses/MIT
// @run-at      document-end
// @grant       none
// ==/UserScript==

window.dpp = {};

(function(gobj) {

    /* global $, wkof */

    //===================================================================
    // Initialization of the Wanikani Open Framework.
    //-------------------------------------------------------------------
    var script_name = 'Dashboard Progress Plus';
    if (!window.wkof) {
        if (confirm(script_name+' requires Wanikani Open Framework.\nDo you want to be forwarded to the installation instructions?')) {
            window.location.href = 'https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549';
        }
        return;
    }

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

    //========================================================================
    // Global variables
    //-------------------------------------------------------------------
    var settings, settings_dialog;

    //========================================================================
    // Load the script settings.
    //-------------------------------------------------------------------
    function load_settings() {
        var defaults = {
            visible_items: 'all',
            locked_position: 'first',
            show_90percent: true,
            show_char: true,
            show_lock_icon: true,
            show_lesson_icon: true,
            enable_popup: true,
            show_meaning: true,
            show_reading: true,
            show_srs: true,
            show_next_review: true,
            show_passed: true,
            time_format: '12hour',
        };
        return wkof.Settings.load('dpp', defaults).then(function(data){
            settings = wkof.settings.dpp;
        });
        if (settings.visible_items === 'appr_only') settings.visible_items = 'non_passed';
        if (settings.visible_items === 'guru_only') settings.visible_items = 'passed';
    }

    //========================================================================
    // Open the settings dialog
    //-------------------------------------------------------------------
    function open_settings() {
        var config = {
            script_id: 'dpp',
            title: 'Dashboard Progress Plus',
            on_save: settings_saved,
            on_refresh: refresh_settings,
            content: {
                tabs: {type:'tabset', content: {
                    pgLayout: {type:'page', label:'Main View', hover_tip:'Settings for the main view.', content: {
                        visible_items: {type:'dropdown', label:'Visible Items', default:'all', content:{all:'All Items',non_passed:'Non-passed (Apprentice)',passed:'Passed Items (Guru+)'}, hover_tip:'Choose which items to show.'},
                        show_90percent: {type:'checkbox', label:'Show 90% Bracket', default:true, hover_tip:'Show the bracket around 90% of items.'},
                        show_char: {type:'checkbox', label:'Show Kanji/Radical', default:true, hover_tip:'Show the kanji or radical inside each tile.'},
                        show_lock_icon: {type:'checkbox', label:'Show Lock Icon', default:true, hover_tip:'Show a lock icon on locked items.'},
                        show_lesson_icon: {type:'checkbox', label:'Show Lesson Icon', default:true, hover_tip:'Show an "L" icon on pending lessons.'},
                        locked_position: {type:'dropdown', label:'Locked Item Position', default:'first', content:{first:'First',last:'Last'}, hover_tip:'Choose where locked items are placed.'},
                    }},
                    pgPopupInfo: {type:'page', label:'Pop-up Info', hover_tip:'Information shown in the popup box.', content: {
                        enable_popup: {type:'checkbox', label:'Enable Pop-up Info Box', default:true, refresh_on_change:true, hover_tip:'Choose whether to show pop-up info box when hovering over an item.'},
                        grpPopupInfo: {type:'group', label:'Pop-up Info', hover_tip:'Information to display in the pop-up box.', content:{
                            show_meaning: {type:'checkbox', label:'Show Meaning', default:true, hover_tip:'Choose whether to show the item\'s meaning in the pop-up info.'},
                            show_reading: {type:'checkbox', label:'Show Reading', default:true, hover_tip:'Choose whether to show the item\'s reading in the pop-up info.'},
                            show_srs: {type:'checkbox', label:'Show SRS Level', default:true, hover_tip:'Choose whether to show the item\'s SRS level in the pop-up info.'},
                            show_next_review: {type:'checkbox', label:'Show Next Review Date', default:true, hover_tip:'Choose whether to show the item\'s next review date in the pop-up info.'},
                            show_passed: {type:'checkbox', label:'Show Passed Date', default:true, hover_tip:'Choose whether to show the date that the item passed in the pop-up info.'},
                            time_format: {type:'dropdown', label:'Time Format', default:'12hour', content:{'12hour':'12-hour','24hour':'24-hour'}, hover_tip:'Display time in 12 or 24-hour format.'},
                        }}
                    }}
                }}
            }
        };
        var settings_dialog = new wkof.Settings(config);
        settings_dialog.open();
    }

    //========================================================================
    // Refresh settings dialog
    //------------------------------------------------------------------------
    function refresh_settings(settings) {
        if (settings.enable_popup) {
            $('#dpp_show_meaning').prop('disabled', false).closest('.row').removeClass('disabled');
            $('#dpp_show_reading').prop('disabled', false).closest('.row').removeClass('disabled');
            $('#dpp_show_srs').prop('disabled', false).closest('.row').removeClass('disabled');
            $('#dpp_show_next_review').prop('disabled', false).closest('.row').removeClass('disabled');
            $('#dpp_show_passed').prop('disabled', false).closest('.row').removeClass('disabled');
            $('#dpp_time_format').prop('disabled', false).closest('.row').removeClass('disabled');
        } else {
            $('#dpp_show_meaning').prop('disabled', true).closest('.row').addClass('disabled');
            $('#dpp_show_reading').prop('disabled', true).closest('.row').addClass('disabled');
            $('#dpp_show_srs').prop('disabled', true).closest('.row').addClass('disabled');
            $('#dpp_show_next_review').prop('disabled', true).closest('.row').addClass('disabled');
            $('#dpp_show_passed').prop('disabled', true).closest('.row').addClass('disabled');
            $('#dpp_time_format').prop('disabled', true).closest('.row').addClass('disabled');
        }
    }

    //========================================================================
    // Startup
    //-------------------------------------------------------------------
    function startup() {
        install_css();
        install_menu();
        init_ui();

        wkof.ItemData.get_items({
            wk_items:{
                options:{
                    assignments:true
                },
                filters:{
                    level:'+0',
                    item_type:'radical,kanji',
                }
            }
        })
        .then(process_items);
    }

    //========================================================================
    // CSS Styling
    //-------------------------------------------------------------------
    var progress_css =
        '#wkofs_dpp .row.disabled label {opacity:0.5;}'+

        'div.progress-entries {grid-gap:12px 0px;}'+
        'div.progress-entry {padding:4px;}'+
        '.progress-entry.pct90 {background:#fff; border-radius:0; border-color:#777; border-style:solid; border-width:1px 0; padding-top:3px; padding-bottom:3px;}'+
        '.progress-entry.pct90.pct90_left {border-left-width:1px; border-top-left-radius:7px; border-bottom-left-radius:7px; padding-left:3px;}'+
        '.progress-entry.pct90.pct90_right {border-right-width:1px; border-top-right-radius:7px; border-bottom-right-radius:7px; padding-right:3px;}'+
        '.progression[data-hide-char="true"] .progress-entry a {color:transparent; text-shadow:unset;}'+
        '.progress-entry.dpp-noshow {display:none;}'+

        // Radical colors
        '.progress-entry[data-srs-lvl="-1"] .radical-icon, .progress-entry[data-srs-lvl="-1"] .kanji-icon {background-repeat:no-repeat;background-image: url("'+
        'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACcAAAAnCAYAAACMo1E1AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccll'+
        'PAAAAF5JREFUeNrs07ENwCAMBEDwspkp0zpBQiJN+i+OAqSvXjY3u/se51z7jcgqtdi6KrXYyua71pFY7Du5yH9XqcWAAAIIIIAAAggggAACCCCAAA'+
        'IIIIAAAgggfrNHgAEAXq5IabsNBOwAAAAASUVORK5CYII='+
        '");}'+
        '.progress-entries[data-lock-icon="y"] .progress-entry[data-srs-lvl="-1"] .radical-icon::before, .progress-entries[data-lock-icon="y"] .progress-entry[data-srs-lvl="-1"] .kanji-icon::before {'+
        '  content:"\\f023";font-family:"FontAwesome";font-size:13pt;color:#ff8;position:relative;left:-14px;top:-18px;-webkit-text-stroke:1px black;}'+
        '.progress-entries[data-lesson-icon="y"] .progress-entry[data-srs-lvl="0"] .radical-icon::before, .progress-entries[data-lesson-icon="y"] .progress-entry[data-srs-lvl="0"] .kanji-icon::before {'+
        '  content:"L";font-family:monospace;font-size:8px;font-weight:bold;color:black;position:relative;left:-14px;top:-22px;border-radius:50%;border:1px solid black; width:12px; height:12px; display:inline-block; padding:0; margin:0; line-height:12px;background-color:#ff8;}'+
        '.progress-entry[data-srs-lvl="-1"] .radical-icon {background-color:#00aaff;}'+
        '.progress-entry[data-srs-lvl="0"] .radical-icon {background-color:#00aaff;}'+
        '.progress-entry[data-srs-lvl="1"] .radical-icon {background-color:#00aaff;}'+
        '.progress-entry[data-srs-lvl="2"] .radical-icon {background-color:#00aaff; background-image:linear-gradient(0deg,#00aaff,#00aaff);}'+
        '.progress-entry[data-srs-lvl="3"] .radical-icon {background-color:#00aaff; background-image:linear-gradient(0deg,#00aaff,#00aaff);}'+
        '.progress-entry[data-srs-lvl="4"] .radical-icon {background-color:#00aaff; background-image:linear-gradient(0deg,#00aaff,#00aaff);}'+
        '.progress-entry[data-srs-lvl="5"] .radical-icon {background-color:#b69acd; background-image:linear-gradient(0deg,#9065b3,#b69acd);}'+
        '.progress-entry[data-srs-lvl="6"] .radical-icon {background-color:#b69acd; background-image:linear-gradient(0deg,#9065b3,#b69acd);}'+
        '.progress-entry[data-srs-lvl="7"] .radical-icon {background-color:#9aa5cf; background-image:linear-gradient(0deg,#7483be,#9aa5cf);}'+
        '.progress-entry[data-srs-lvl="8"] .radical-icon {background-color:#a3c3d3; background-image:linear-gradient(0deg,#75a5bd,#a3c3d3);}'+
        '.progress-entry[data-srs-lvl="9"] .radical-icon {background-color:#999999; background-image:linear-gradient(0deg,#737373,#999999);}'+

        // Kanji colors
        '.progress-entry[data-srs-lvl="-1"] .radical-icon {background-color:#00aaff;}'+
        '.progress-entry[data-srs-lvl="0"] .kanji-icon {background-color:#ff00aa;}'+
        '.progress-entry[data-srs-lvl="1"] .kanji-icon {background-color:#ff00aa; background-image:linear-gradient(0deg,#cc0088,#ff00aa);}'+
        '.progress-entry[data-srs-lvl="2"] .kanji-icon {background-color:#ff00aa; background-image:linear-gradient(0deg,#cc0088,#ff00aa);}'+
        '.progress-entry[data-srs-lvl="3"] .kanji-icon {background-color:#ff00aa; background-image:linear-gradient(0deg,#cc0088,#ff00aa);}'+
        '.progress-entry[data-srs-lvl="4"] .kanji-icon {background-color:#ff00aa; background-image:linear-gradient(0deg,#cc0088,#ff00aa);}'+
        '.progress-entry[data-srs-lvl="5"] .kanji-icon {background-color:#b69acd; background-image:linear-gradient(0deg,#9065b3,#b69acd);}'+
        '.progress-entry[data-srs-lvl="6"] .kanji-icon {background-color:#b69acd; background-image:linear-gradient(0deg,#9065b3,#b69acd);}'+
        '.progress-entry[data-srs-lvl="7"] .kanji-icon {background-color:#9aa5cf; background-image:linear-gradient(0deg,#7483be,#9aa5cf);}'+
        '.progress-entry[data-srs-lvl="8"] .kanji-icon {background-color:#a3c3d3; background-image:linear-gradient(0deg,#75a5bd,#a3c3d3);}'+
        '.progress-entry[data-srs-lvl="9"] .kanji-icon {background-color:#999999; background-image:linear-gradient(0deg,#737373,#999999);}'+

        '.progress-entries .popover {border-radius:5px; border:5px solid rgba(75,75,75,0.8); box-shadow:none;}'+
        '.progression .popover.right .arrow {border-right-color:rgba(75,75,75,0.8); left:-16px;}'+
        '.progression .popover.right .arrow:after {border-color:transparent;}'+
        '.progression .popover.left .arrow {border-left-color:rgba(75,75,75,0.55);}'+
        '.progression .popover .popover-content {text-shadow: 0 1px 0 #fff;}'+
        '.progression .popover .srs {font-size:75%; font-weight:bold;}'+
        '.progression .popover .next {font-size:75%; font-weight:bold;}'+

        '.progression[data-show-item-name="false"] .lattice-single-character li>a {color:rgba(0,0,0,0);text-shadow:0 0 0 rgba(0,0,0,0);}';

    //========================================================================
    // Install stylesheet.
    //-------------------------------------------------------------------
    function install_css() {
        $('head').append('<style>'+progress_css+'</style>');
    }

    //========================================================================
    // Install menu link
    //-------------------------------------------------------------------
    function install_menu() {
		// Set up menu item to open script.
		wkof.Menu.insert_script_link({name:'dpp',submenu:'Settings',title:'Dashboard Progress Plus',on_click:open_settings});
    }

    //========================================================================
    // Initialize the user interface.
    //-------------------------------------------------------------------
    function init_ui() {
        $('.progression').attr('data-hide-char', !settings.show_char);
        if (settings.enable_popup) {
            $('.progress-entries').popover({
                selector:'.progress-entry',
                trigger:'hover',
                animation: false,
                html:true,
                content:generate_item_info,
                placement:place_item_info,
            });
        } else {
            $('.progress-entries').popover('destroy');
        }
        $('.progress-entries').attr('data-lock-icon', (settings.show_lock_icon ? 'y' : 'n'));
        $('.progress-entries').attr('data-lesson-icon', (settings.show_lesson_icon ? 'y' : 'n'));
    }

    //========================================================================
    // Handler for when user clicks 'Save' in the settings window.
    //-------------------------------------------------------------------
    function settings_saved(new_settings) {
        init_ui();
        populate_item_info('radical');
        populate_item_info('kanji');
    }

    //========================================================================
    // Populate level info from API.
    //-------------------------------------------------------------------
    function process_items(data) {
        gobj.items = wkof.ItemData.get_index(data, 'item_type');

        populate_item_info('radical');
        populate_item_info('kanji');
    }

    //========================================================================
    // Generate content for popover.
    //-------------------------------------------------------------------
    function generate_item_info() {
        // Populate the next review date.
        var elem = $(this)
        var item = $(this).data('item');
        var html = [];

        // Functions for filtering and sorting information.
        function accepted_first(a, b) {
            if (a.accepted_answer === b.accepted_answer) return 0;
            if (a.accepted_answer) return -1;
            return 1;
        }
        function primary(a) {return a.primary;}
        function to_meaning(a) {return a.meaning;}
        function to_reading(a) {return a.reading;}

        // Meaning
        if (settings.show_meaning) {
            var meaning = item.data.meanings.filter(primary).sort(accepted_first).map(to_meaning).join(', ');
            html.push('<span class="meaning">'+meaning+'</span>');
        }

        // Reading
        if (settings.show_reading && item.object === 'kanji') {
            var reading = item.data.readings.filter(primary).sort(accepted_first).map(to_reading).join(', ');
            html.push('<span class="reading" lang="ja">'+reading+'</span>');
        }

        // SRS Stage
        if (settings.show_srs && item.assignments && item.assignments.srs_stage_name) {
            html.push('<span class="srs">SRS: '+item.assignments.srs_stage_name+'</span>');
        }

        // Pass Date and Next Review
        var next = [];
        var date;
        if (item.assignments && item.assignments.available_at) {
            if (item.assignments.passed_at) {
                if (settings.show_passed) {
                    if (item.assignments.passed_at) {
                        date = formatDate(new Date(item.assignments.passed_at), false /* is_next_date */);
                    } else {
                        date = 'A long time ago...';
                    }
                    next.push('Passed: '+date);
                }
            }
            if (item.assignments.srs_stage == 9) {
                if (settings.show_passed) {
                    date = formatDate(new Date(item.assignments.burned_at), false /* is_next_date */);
                    next.push('Burned: '+date);
                } else {
                    next.push('Burned!');
                }
            } else if (settings.show_next_review) {
                date = formatDate(new Date(item.assignments.available_at), true /* is_next_date */);
                next.push('Next: '+date);
            }
        } else if (item.assignments && item.assignments.unlocked_at) {
            next.push('Lesson: Available Now');
        } else {
            next.push('Locked!');
        }

        // Populate remaining data for popup window.
        if (next.length !== 0) {
            html.push('<span class="next">'+next.join('<br>')+'</span>');
        }

        return html.join('<br>');
    }

    //========================================================================
    // Determine whether the popover should be to the left or right of the element.
    //-------------------------------------------------------------------
    function place_item_info() {
        var elem = this.$element.eq(0);
        var parent = elem.parent();
        return ((elem.position().left + elem.width() - parent.position().left) <= (parent.width()/2) ? 'right' : 'left');
    }

    //========================================================================
    // Determine whether the item is unlocked.
    //-------------------------------------------------------------------
    function is_unlocked(item) {
        return (item && item.assignments && item.assignments.unlocked_at ? true : false);
    }

    //========================================================================
    // Determine whether the item is "Initiate" stage (i.e. unlocked but lesson not done).
    //-------------------------------------------------------------------
    function is_initiate(item) {
        return (item && item.assignments && item.assignments.unlocked_at ? true : false);
    }

    //========================================================================
    // Determine whether the item has been previously Guru'd.
    //-------------------------------------------------------------------
    function is_passed(item) {
        return (item && item.assignments && item.assignments.passed_at ? true : false);
    }

    //========================================================================
    // Populate level info from API.
    //-------------------------------------------------------------------
    function populate_item_info(itype) {
        var group,elems;
        if (itype === 'radical') {
            group = $('.progress-entries').eq(0);
            group.attr('data-type','radical');
        } else {
            group = $('.progress-entries').eq(1);
            group.attr('data-type','kanji');
        }
        elems = group.find('.progress-entry');
        var items = wkof.ItemData.get_index(gobj.items[itype], 'slug');

        // Populate item data.
        elems.each(function(idx, elem){
            elem = $(elem);
            elem.removeAttr('title');
            var a = elem.find('a');
            var slug;
            if (itype === 'radical') {
                slug = a.attr('href').split('/')[2];
            } else {
                slug = a.text();
            }
            var item = items[slug];
            elem.data('item', item);

            elem.addClass('dpp-progress');

            // Populate 'data-srs-lvl', which is a styling selector.
            var srs = (item.assignments && item.assignments.srs_stage ? item.assignments.srs_stage : (is_initiate(item) ? 0 : -1)); // -1 == locked
            elem.attr('data-srs-lvl', srs);
        });

        // Sort items by srs level, then review date, then meaning.
        var locked_last = (settings.locked_position === 'last');
        var srs_locked = (locked_last ? 10 : -1);
        elems.sort(function(a,b){
            if (itype === 'radical') {
                a = items[$(a).find('.radical-icon').attr('href').split('/')[2]];
                b = items[$(b).find('.radical-icon').attr('href').split('/')[2]];
            } else {
                a = items[$(a).text()];
                b = items[$(b).text()];
            }

            if (!locked_last) {
                var a_passed = is_passed(a);
                var b_passed = is_passed(b);
                if (!a_passed && b_passed) return -1;
                if (a_passed && !b_passed) return 1;
            }
            var a_srs = (a && a.assignments && a.assignments.srs_stage ? a.assignments.srs_stage : (is_initiate(a) ? 0 : srs_locked));
            var b_srs = (b && b.assignments && b.assignments.srs_stage ? b.assignments.srs_stage : (is_initiate(b) ? 0 : srs_locked));
            if (a_srs < b_srs) return -1;
            if (a_srs > b_srs) return 1;
            if (a_srs != 0) {
                var a_avail = (a && a.assignments && a.assignments.available_at ?
                               new Date(a.assignments.available_at).getTime() : Number.MAX_SAFE_INTEGER);
                var b_avail = (b && b.assignments && b.assignments.available_at ?
                               new Date(b.assignments.available_at).getTime() : Number.MAX_SAFE_INTEGER);
                if (a_avail < b_avail) return 1;
                if (a_avail > b_avail) return -1;
            }
            if (a.data.slug < b.data.slug) return -1;
            if (a.data.slug > b.data.slug) return 1;
            return 0;
        });
        elems.detach().appendTo(group);

        elems.removeClass('dpp-noshow pct90_left pct90 pct90_right');
        var srslvl;
        switch (settings.visible_items) {
            case 'non_passed':
                elems.each(function(idx, elem){
                    elem = $(elem);
                    var item = items[elem.text()];
                    if (is_passed(item)) elem.addClass('dpp-noshow');
                });
                break;
            case 'passed':
                elems.each(function(idx, elem){
                    elem = $(elem);
                    var item = items[elem.text()];
                    if (!is_passed(item)) elem.addClass('dpp-noshow');
                });
                break;
        }

        if (settings.show_90percent && settings.visible_items !== 'passed' && itype === 'kanji') {
            // Add marker at 90%, indicating when level will be complete.
            var needed = Math.ceil(elems.length * 0.9);
            var locked = 0;
            var passed = 0;
            var passed_hidden = 0;
            var visible = 0;
            elems.each(function(idx, elem){
                elem = $(elem);
                var item = items[$(elem).text()];
                if (is_passed(item)) {
                    passed++;
                    if (elem.hasClass('dpp-noshow')) passed_hidden++;
                }
                if (!is_unlocked(item)) locked++;
            });

            var visible_elems = elems.filter(':not(.dpp-noshow)');
            var visible_len = visible_elems.length;

            var first = elems.length - needed;
            var last = first + (needed - 1) - passed_hidden;
            if (locked_last) {
                var shift = Math.min(first, locked);
                first -= shift;
                last -= shift;
            }
            if (first <= last) {
                visible_elems.eq(first).addClass('pct90_left');
                visible_elems.slice(first, last + 1).addClass('pct90');
                visible_elems.eq(last).addClass('pct90_right');
            }
        }
    }

    //========================================================================
    // Print date in pretty format.
    //-------------------------------------------------------------------
    function formatDate(d, is_next_date){
        var s = '';
        var now = new Date();
        var YY = d.getFullYear(),
            MM = d.getMonth(),
            DD = d.getDate(),
            hh = d.getHours(),
            mm = d.getMinutes(),
            one_day = 24*60*60*1000;

        if (is_next_date && d < now) return "Available Now";
        var same_day = ((YY == now.getFullYear()) && (MM == now.getMonth()) && (DD == now.getDate()) ? 1 : 0);

        //    If today:  "Today 8:15pm"
        //    otherwise: "Wed, Apr 15, 8:15pm"
        if (same_day) {
            s += 'Today ';
        } else {
            s += ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()]+', '+
                ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][MM]+' '+DD+', ';
        }
        if (settings.time_format === '24hour') {
            s += ('0'+hh).slice(-2)+':'+('0'+mm).slice(-2);
        } else {
            s += (((hh+11)%12)+1)+':'+('0'+mm).slice(-2)+['am','pm'][Math.floor(d.getHours()/12)];
        }

        // Append "(X days)".
        if (is_next_date && !same_day) {
            var days = (Math.floor((d.getTime()-d.getTimezoneOffset()*60*1000)/one_day)-Math.floor((now.getTime()-d.getTimezoneOffset()*60*1000)/one_day));
            if (days) s += ' ('+days+' day'+(days>1?'s':'')+')';
        }

        return s;
    }

})(window.dpp);