Wanikani Level-Up Time Assistant

Shows the earliest date and time you can level up if your reviews are correct. Adds an indication if you have items available for lesson/review that are needed to advance your current level.

当前为 2023-11-05 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Wanikani Level-Up Time Assistant
// @namespace    https://greasyfork.org/en/users/11878
// @version      1.2.3
// @description  Shows the earliest date and time you can level up if your reviews are correct. Adds an indication if you have items available for lesson/review that are needed to advance your current level.
// @author       Inserio
// @match        https://www.wanikani.com/dashboard*
// @match        https://www.wanikani.com/
// @grant        none
// @license      MIT
// ==/UserScript==
/* global wkof */

window.lu = {};

(function(lu_obj) {
    //========================================================================
    // Initialization of the Wanikani Open Framework.
    //-------------------------------------------------------------------

    const script_name = 'Wanikani Level Up Time';
    let wkof_version_needed = '1.0.27';
    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;
    }
    if (wkof.version.compare_to(wkof_version_needed) === 'older') {
        if (confirm(script_name+' requires Wanikani Open Framework version '+wkof_version_needed+'.\nDo you want to be forwarded to the update page?'))
            window.location.href = 'https://greasyfork.org/en/scripts/38582-wanikani-open-framework';
        return;
    }

    //========================================================================
    // Globals
    //-------------------------------------------------------------------

    var config = {
        log: {
            enabled: true,
            detailed: true
        },
        callback: null
    };
    var items_by_subject_id;
    var items_by_type;

    //========================================================================
    // Startup
    //-------------------------------------------------------------------

    wkof.include('ItemData');
    startup();

    wkof.ready('ItemData').then(fetch_items);

    function startup() {
        lu_obj.items_with_soonest_assignments = [];
        lu_obj.items_not_passed_with_assignments_available = [];
        install_css();
        init_ui();
    }

    /**
     * Install stylesheet.
     */
    function install_css() {
        const lu_css =
              '#lu_container { margin: auto auto 12px auto; color: #555; font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; font-weight: 300; text-shadow: 0 1px 0 #fff; font-size:13px; }'+
              '#lu_container i { padding-left:5px; padding-right:5px; font-size:14px; }';
        $('head').append('<style>'+lu_css+'</style>');
    }

    /**
     * Initialize the user interface.
     */
    function init_ui() {
        const html =
              "<div id='lu_container'>"+
              "<i id='lu_arrow_up' class='fa-solid fa-circle-arrow-up' title='"+get_text_for_icon_tooltip()+"'></i>"+
              "<strong>Earliest Level Up: </strong><span id='lu_level_up_date'></span>"+
              "</div>";
        $('.progress-and-forecast > .wk-panel--review-forecast > :first-child').before(html);
    }

    //========================================================================
    // Populate level info from API.
    //-------------------------------------------------------------------
    function fetch_items() {
        // Fetch only radicals and kanji for current level.
        // Include /subjects and /assignments endpoints
        wkof.ItemData.get_items({
            wk_items:{
                options:{
                    assignments:true,
                    review_statistics:true
                },
                filters:{
                    level:'+0',
                    item_type:'rad,kan',
                }
            }
        }).then(prepare_items);
    }

    function prepare_items(items) {
        lu_obj.load_time = new Date();

        items_by_type = wkof.ItemData.get_index(items, 'item_type');
        items_by_subject_id = wkof.ItemData.get_index(items, 'subject_id');

        // Add "earliest_study_date" and "earliest_guru_date" properties to items
        // Need to parse radicals first so that locked kanji get the proper dates assigned to them
        add_dates_to_items((items_by_type.radical?items_by_type.radical:[]).concat(items_by_type.kanji));

        lu_obj.items = items.sort(get_sort_method('+earliest_study_date','+earliest_guru_date','+data.subject_id'));

        // Cache these filters for quick lookups
        lu_obj.items_not_locked_and_not_passed = get_not_locked_but_not_passed_items(lu_obj.items);
        lu_obj.items_not_passed_with_assignments_available = get_not_passed_items_with_available_assignments(lu_obj.items);
        lu_obj.items_with_soonest_assignments = get_next_soonest_study_items(lu_obj.items_not_locked_and_not_passed);

        // Log the results to the console
        log_base_items_stats();
        // Prevent non-intentional logging
        config.log.enabled = false;
        process_items();
        setup_next_reviews_callback();
    }

    function process_items() {
        lu_obj.level_up_date = get_level_up_date();
        update_ui();
        let lu_level_up_date = document.getElementById('lu_level_up_date');
        let lu_arrow_up = document.getElementById('lu_arrow_up');
        if (lu_arrow_up) {
            lu_arrow_up.onmouseover = function(){update_ui('lu_arrow_up');};
        }
        if (lu_level_up_date) {
            lu_level_up_date.onmouseover = function(){update_ui('lu_level_up_date');};
            lu_level_up_date.onclick = function(){config.log.enabled = true; fetch_items();};
        }
    }

    function update_date_title() {
        let lu_level_up_date = document.getElementById('lu_level_up_date');
        if (!lu_level_up_date) return;
        let dateOutput = format_date_to_standard_output(lu_obj.level_up_date, false);
        let wait_time = format_two_dates_diff_to_minimal_output(lu_obj.level_up_date,lu_obj.load_time, true);
        lu_level_up_date.innerHTML = dateOutput;
        lu_level_up_date.title = (wait_time==='Now'?'Available now':wait_time)+'\nClick to update data (and repeat logging to console)';
    }

    function update_arrow_title() {
        let lu_arrow_up = document.getElementById('lu_arrow_up');
        if (!lu_arrow_up) return;
        let title = '';
        if (lu_obj.items_with_soonest_assignments.length>0) {
            let review_time = format_date_to_standard_output(lu_obj.items_with_soonest_assignments[0].earliest_study_date,true, true);
            let next_items = lu_obj.items_with_soonest_assignments;
            if (review_time === 'Now') {
                title = `${lu_obj.items_with_soonest_assignments.length} item${lu_obj.items_with_soonest_assignments.length===1?' needed to pass this level is':'s needed to pass this level are'} currently available to study\nClick here to proceed to a new session`;
                next_items = unique_from(get_next_soonest_study_items(lu_obj.items_not_locked_and_not_passed,lu_obj.items_with_soonest_assignments));
                if (next_items.length>0) {
                    title += '\n\n';
                    review_time = format_date_to_standard_output(next_items[0].earliest_study_date,true, true);
                }
            }
            if (next_items.length>0) {
                title += `The next ${next_items.length} item${next_items.length>1?'s':''} of the ones needed to pass this level will arrive at:\n${(review_time)}`;
            }
        } else {
            title = get_text_for_icon_tooltip();
        }
        lu_arrow_up.title = title;
        if (lu_obj.items_not_passed_with_assignments_available.length>0) {
            let destination = lu_obj.items_not_passed_with_assignments_available.find(a=>!a.assignments.available_at) ? 'lesson' : 'review';
            lu_arrow_up.onclick = function(){window.location='https://www.wanikani.com/subjects/'+destination;};
            lu_arrow_up.style.setProperty('color', '#00ff00');
            lu_arrow_up.classList.add("fa-beat");
        } else {
            lu_arrow_up.onclick = null;
            lu_arrow_up.style.removeProperty('color');
            lu_arrow_up.classList.remove("fa-beat");
        }
    }

    function update_ui(element_id) {
        lu_obj.load_time = new Date();
        if (!element_id || element_id === 'lu_level_up_date')
            update_date_title();
        if (!element_id || element_id === 'lu_arrow_up')
            update_arrow_title();
    }

    function setup_next_reviews_callback() {
        if (config.callback) {
            clearTimeout(config.callback);
            config.callback = null;
        }
        if (lu_obj.items_with_soonest_assignments.length<=0) return;
        let time_diff = lu_obj.items_with_soonest_assignments[0].earliest_study_date.getTime() - (new Date()).getTime();
        if (time_diff <= 0) return;
        setTimeout(function() {
            config.callback = null;
            fetch_items();
        }, time_diff);
    }

    function log_base_items_stats() {
        if (!config.log.enabled) return;
        const max_date_len = 48;
        let items_not_passed_by_type = wkof.ItemData.get_index(get_not_passed_items(lu_obj.items), 'item_type');
        let items_locked_by_type = wkof.ItemData.get_index(get_locked_items(lu_obj.items), 'item_type');
        for (let itype of Object.keys(items_not_passed_by_type).sort((a,b)=>a.localeCompare(b)*-1)) {
            let not_passed_items = items_not_passed_by_type[itype];
            if (!not_passed_items || not_passed_items.length<=0) continue;
            let str = [`${not_passed_items.length} remaining ${itype}${itype==='radical'&&not_passed_items.length>1?'s':''} to guru`+
                       (items_locked_by_type[itype]&&items_locked_by_type[itype].length>0?' ('+items_locked_by_type[itype].length+' of which are still locked)':'')];
            if (config.log.detailed) {
                for (let i=0; i<not_passed_items.length; i++) {
                    let itm = not_passed_items[i];
                    let next_study_time = format_date_to_standard_output(itm.earliest_study_date,true);
                    let earliest_guru_time = format_date_to_standard_output(itm.earliest_guru_date,true);
                    str.push((!itm.assignments||!itm.assignments.unlocked_at?'🔒 ':'')+(itm.data.characters?itm.data.characters:itm.data.slug)+'\t'+
                             '| Stage: '+(itm.assignments?itm.assignments.srs_stage:0)+'/5\t'+
                             '| Next Study: '+next_study_time+'\t'.repeat(Math.ceil((max_date_len-next_study_time.length)/8))+
                             '| Earliest Guru: '+earliest_guru_time+'\t'.repeat(Math.ceil((max_date_len-earliest_guru_time.length)/8))+
                             '| '+itm.data.document_url);
                }
            }
            console.log(str.join('\n'));
        }
    }

    //========================================================================
    // Formatting
    //-------------------------------------------------------------------

    function get_text_for_icon_tooltip() {
        return `${lu_obj.items_not_passed_with_assignments_available.length} item${lu_obj.items_not_passed_with_assignments_available.length===1?'':'s'} needed to level ${(lu_obj.items_not_passed_with_assignments_available.length===1?'is':'are')} currently available to study`;
    }

    function format_two_dates_diff_to_minimal_output(date, date2, include_seconds) {
        let diff = Math.max(0, Math.trunc(date.getTime()/1000)-Math.trunc(date2.getTime()/1000));
        let dd = Math.floor(diff / 86400);
        diff -= dd*86400;
        let hh = Math.floor(diff / 3600);
        diff -= hh*3600;
        let mm = Math.floor(diff / 60);
        diff -= mm*60;
        let ss = diff;
        if (dd > 0) {
            return dd+' day'+(dd===1?'':'s')+', '+hh+' hour'+(hh===1?'':'s')+', '+mm+' min'+(mm===1?'':'s')+(include_seconds?', '+ss+' sec'+(ss===1?'':'s'):'');
        } else if (hh > 0) {
            return hh+' hour'+(hh===1?'':'s')+', '+mm+' min'+(mm===1?'':'s')+(include_seconds?', '+ss+' sec'+(ss===1?'':'s'):'');
        } else if (mm > 0 || ss > 0) {
            if (!include_seconds && ss > 30) mm++;
            return mm+' min'+(mm===1?'':'s')+(include_seconds?', '+ss+' sec'+(ss===1?'':'s'):'');
        } else {
            return 'Now';
        }
    }

    function format_date_to_standard_output(date, include_differential, include_seconds) {
        if (!(date instanceof Date)) date = new Date(date);
        if (date.getTime() <= lu_obj.load_time.getTime()) return "Now";
        let str = date.toLocaleString([],{weekday:"short",month:"short",day:"numeric",hour12:false,hour:"numeric",minute:"numeric"});
        if (!include_differential) return str;
        return str +' ('+format_two_dates_diff_to_minimal_output(date,lu_obj.load_time, include_seconds)+')';
    }

    //========================================================================
    // Transformers and Helpers
    //-------------------------------------------------------------------

    /**
     * Returns a non-destructive Array of elements that are not found in
     * any of the parameter arrays.
     * Assumes all items have a property named "id"
     *
     * @param {...Array} var_args   Arrays to compare.
     */
    function unique_from(arr1, ...args) {
        if (!args.length) return [];
        let out = [];
        let map = new Map();
        for (let n=0; n < args.length; n++) {
            let a2 = args[n];
            if (!(a2 instanceof Array))
                throw new TypeError( 'argument ['+n+'] must be an Array' );
            // Add existing id from the array to the map
            for (let i=0; i<a2.length; i++)
                map.set(a2[i].id, true);
        }
        // Add to the new array all items that aren't included in the map (map lookUp is O(1) complexity)
        for(let i=0; i<arr1.length; i++)
            if (!map.get(arr1[i].id))
                out.push(arr1[i]);
        return out;
    }

    /**
     * Get an Array sort function with multiple subarray fields.
     *   Prefix letter allows specifying whether to sort ascending "+" or descending "-" (default: ascending)
     *   Splits and recurses properties properly on periods
     *   e.g.
     *    let arr = [{obj:{prop1:'1'}, prop2: 3},{obj:{prop1:'3'}, prop2: 4},{obj:{prop1:'3'}, prop2: 2},{obj:{prop1:'4'}, prop2: 2}];
     *    arr.sort(get_sort_method('+prop2','-obj.prop1'));
     *    // [{obj:{prop1:'4'}, prop2: 2},{obj:{prop1:'3'}, prop2: 2},{obj:{prop1:'1'}, prop2: 3},{obj:{prop1:'3'}, prop2: 4}]
     */
    function get_sort_method(){
        let argsArr = Array.prototype.slice.call(arguments);
        return function(a, b){
            for(let x in argsArr){
                let strStart = 1;
                let op = argsArr[x].substring(0,1);
                if (op !== "-" && op !== "+") {op = "+";strStart = 0;}
                let prop = argsArr[x].substring(strStart);
                prop = prop.split('.');
                let len = prop.length;
                let i = 0;
                let ax = a;
                let bx = b;
                let cx;
                while(i<len) {ax = ax[prop[i]]; bx = bx[prop[i]]; i++;}
                ax = typeof ax == "string" ? ax.toLowerCase() : ax / 1;
                bx = typeof bx == "string" ? bx.toLowerCase() : bx / 1;
                if(op === "-"){cx = ax; ax = bx; bx = cx;}
                if(ax !== bx){return ax < bx ? -1 : 1;}
            }
        };
    }

    function add_dates_to_items(items) {
        for (let i=0; i<items.length; i++) {
            let itm = items[i];
            itm.earliest_study_date = itm.assignments && itm.assignments.unlocked_at ? new Date(itm.assignments.started_at ? itm.assignments.available_at : itm.assignments.unlocked_at) : get_earliest_unlock_date(itm);
            itm.earliest_guru_date = get_item_guru_date(itm);
        }
    }

    function get_not_passed_items(items) {
        let output_items = [];
        for (let i=0; i<items.length; i++) {
            let itm = items[i];
            if (!itm.assignments || !itm.assignments.passed_at)
                output_items.push(itm);
        }
        return output_items;
    }

    function get_not_locked_but_not_passed_items(items) {
        let output_items = [];
        for (let i=0; i<items.length; i++) {
            let itm = items[i];
            if (itm.assignments && itm.assignments.unlocked_at && !itm.assignments.passed_at)
                output_items.push(itm);
        }
        return output_items;
    }

    function get_not_passed_items_with_available_assignments(items) {
        let output_items = [];
        for (let i=0; i<items.length; i++) {
            let itm = items[i];
            if (itm.assignments && !itm.assignments.passed_at && itm.earliest_study_date.getTime()<=lu_obj.load_time.getTime())
                output_items.push(itm);
        }
        return output_items;
    }

    function get_locked_items(items) {
        let output_items = [];
        for (let i=0; i<items.length; i++) {
            let itm = items[i];
            if (!itm.assignments || !itm.assignments.unlocked_at)
                output_items.push(itm);
        }
        return output_items;
    }

    function get_next_soonest_study_items(items) {
        return items.reduce((acc,itm)=>{
                let min_date = acc.length>0 ? acc[0].earliest_study_date : itm.earliest_study_date;
                if (itm.earliest_study_date.getTime() > lu_obj.load_time.getTime() && itm.earliest_study_date.getTime() < min_date.getTime()) {
                    min_date = itm.earliest_study_date;
                    acc.length = 0;
                }
                if (itm.earliest_study_date.getTime() <= lu_obj.load_time.getTime() || itm.earliest_study_date.getTime() === min_date.getTime())
                    acc.push(itm);
                itm.earliest_guru_date = get_item_guru_date(itm);
                return acc;
            },[]);
    }

    /**
     * Gets the earliest date that the provided item can be unlocked if all components are gurued as soon as possible
     */
    function get_earliest_unlock_date(item) {
        let min_date;
        for (let rad_idx = 0; rad_idx < item.data.component_subject_ids.length; rad_idx++) {
            let rad = items_by_subject_id[item.data.component_subject_ids[rad_idx]];
            if (!rad) continue;
            if (!min_date || rad.earliest_guru_date.getTime() > min_date.getTime())
                min_date = new Date(rad.earliest_guru_date);
        }
        return min_date;
    }

    /**
     * Calculate item guru date
     */
    function get_item_guru_date(item){
        let hours_to_guru = 0;
        if (!item || !item.earliest_study_date && (!item.assignments || !item.assignments.unlocked_at))
            return new Date(0); // This is mostly for debugging. If you see the 12/31/1969 date anywhere, this is where it went wrong.
        if (item.assignments && item.assignments.passed_at)
            return new Date(item.assignments.passed_at);
        switch (item.assignments ? item.assignments.srs_stage : 0) {
            case 0: hours_to_guru += 4+8+23+47; break;
            case 1: hours_to_guru += 8+23+47; break;
            case 2: hours_to_guru += 23+47; break;
            case 3: hours_to_guru += 47; break;
        }
        // Add the hours to the available date, or the unlock date if the item is locked, or current date something went wrong
        let earliest_guru_date = item.earliest_study_date ? new Date(item.earliest_study_date) : (item.assignments ? new Date(item.assignments.available_at ? item.assignments.available_at : item.assignments.unlocked_at) : new Date(lu_obj.load_time));
        if (earliest_guru_date.getTime() < lu_obj.load_time.getTime())
            earliest_guru_date = new Date(lu_obj.load_time);
        earliest_guru_date.setHours(earliest_guru_date.getHours() + hours_to_guru);
        return earliest_guru_date;
    }

    /**
     * Get earliest possible level up date
     * Calculated by sorting the kanji by earliest possible guru date and taking the time from the 90% to last kanji
     */
    function get_level_up_date() {
        let kanji_items = items_by_type.kanji.sort((a,b)=>a.earliest_guru_date.getTime()-b.earliest_guru_date.getTime());
        return new Date(kanji_items[Math.ceil(kanji_items.length * 0.9)-1].earliest_guru_date);
    }

})(window.lu);