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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Wanikani Level-Up Time Assistant
// @namespace    https://greasyfork.org/en/users/11878
// @version      1.5.1
// @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/*
// @grant        none
// @license      MIT
// ==/UserScript==
/* global wkof */

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

    const scriptName = 'Wanikani Level-Up Time Assistant', scriptId = scriptName.substring(9).toLowerCase().replaceAll(/[- ]/g, '_'),
          containerId = 'lu-container', dateId = 'lu-level-up-date', arrowId = 'lu-arrow-up', wkof_version_needed = '1.2.6';
    if (!window.wkof) {
        if (confirm(scriptName+' 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 (window.wkof.version.compare_to(wkof_version_needed) === 'older') {
        if (confirm(scriptName+' 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
    // -------------------------------------------------------------------

    // TODO: Perhaps make the logging option configurable in a settings menu?
    const config = {
        log: {
            enabled: false,
            detailed: true
        },
        loading: false,
        callback: null
    }, dashboard_url = /^\/(dashboard)?$/, srs_stages = ['Unlocked', 'Apprentice 1', 'Apprentice 2', 'Apprentice 3', 'Apprentice 4', 'Guru 1', 'Guru 2', 'Master', 'Enlighten', 'Burn'];
    let items_by_type, items_by_subject_id;

    // ========================================================================
    // Startup
    // -------------------------------------------------------------------
    lu_obj.items_with_soonest_assignments = null;
    lu_obj.items_not_passed_with_assignments_available = null;

    wkof.include('ItemData');
    wkof.on_pageload(dashboard_url, startup);

    function startup() {
        install_css();
        init_ui();
        wkof.ready('ItemData').then(fetch_items);
    }

    /**
     * Install stylesheet.
     */
    function install_css() {
        if (document.getElementById(scriptId+'-style')) return;
        const lu_css = `<style id="${scriptId}-style">#${containerId}{display:flex;align-items:center;justify-content:space-evenly;margin:0 5px 12px 5px;}
#${containerId} #${arrowId}{height:20px;width:20px;padding:3px;font-size:14px;font-family:"Noto Sans JP", "Noto Sans SC", sans-serif;color:white;background-color:darkgray;cursor:default;border-radius:14px;}
#${containerId} #${arrowId}.levelup-items{background-color:#00ff00;cursor:pointer;animation:lu-pulse 1s infinite;}
.hidden {visibility:hidden;}
@keyframes lu-pulse{
    0%,100%{-ms-transform:scale(1);-o-transform:scale(1);-webkit-transform:scale(1);-moz-transform:scale(1);transform:scale(1);}
    50%{-ms-transform:scale(1.25);-o-transform:scale(1.25);-webkit-transform:scale(1.25);-moz-transform:scale(1.25);transform:scale(1.25);}
}</style>`;
        document.getElementsByTagName('head')[0].insertAdjacentHTML('beforeend',lu_css);
    }

    /**
     * Initialize the user interface.
     */
    function init_ui() {
        if (document.getElementById(containerId) !== null) return;
        const lu_html = `<div id="${containerId}" class="hidden"><span id="${arrowId}" title="${get_text_for_icon_tooltip()}">&#x2B06;</span><strong>Earliest Level Up: </strong><span id="${dateId}"></span></div>`;
        document.querySelector('.dashboard__review-forecast > .wk-panel--review-forecast > :first-child').insertAdjacentHTML('beforebegin',lu_html);
    }

    function prepare_radicals(items) {
        lu_obj.load_time = new Date();
        // Add "is_locked", "earliest_study_date", "current_earliest_study_date", and "earliest_guru_date" properties to the `scriptId` property of items
        // Need to parse radicals first so that locked kanji get the proper dates assigned to them
        add_dates_to_items(items);
        items_by_subject_id = get_items_by_index(items, 'id', 'single'); // store items for reference when parsing component_subject_ids
    }

    function prepare_items(items) {
        items_by_type = get_items_by_index(items, 'object', 'array');
        Object.assign(items_by_subject_id, get_items_by_index(items, 'id', 'single')); // add kanji items to the items_by_subject_id object

        // Add "is_locked", "earliest_study_date", "current_earliest_study_date", and "earliest_guru_date" properties to the `scriptId` property of items
        add_dates_to_items(items_by_type.kanji);

        // Sort the items by the current_earliest_study_date, then the earliest_guru_date, then the id. This will determine how they appear in the console.
        lu_obj.items = items.sort(get_sort_method(`+${scriptId}.current_earliest_study_date`,`+${scriptId}.earliest_guru_date`,'+id'));

        // Cache these filters for quick lookups
        lu_obj.items_not_locked_and_not_passed = Array.from(get_not_locked_but_not_passed_items(lu_obj.items));
        lu_obj.items_not_passed_with_assignments_available = Array.from(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();

        // Get the level up date and update the UI
        process_items();

        // Setup a callback to fetch new data and re-run the UI updates when current level radicals/kanji become available
        setup_next_reviews_callback();
    }

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

    function process_items() {
        lu_obj.level_up_date = get_level_up_date();
        let lu_container = document.getElementById(containerId);
        if (!lu_container)
            return;
        if (lu_container.classList.contains('hidden'))
            lu_container.classList.remove('hidden');
        update_ui();
        let lu_level_up_date = document.getElementById(dateId);
        let lu_arrow_up = document.getElementById(arrowId);
        if (lu_arrow_up) {
            lu_arrow_up.onmouseover = function(){update_ui(arrowId);};
        }
        if (lu_level_up_date) {
            lu_level_up_date.onmouseover = function(){update_ui(dateId);};
            lu_level_up_date.onclick = function(){config.log.enabled = true; fetch_items();};
        }
    }

    function update_date_title() {
        let lu_level_up_date = document.getElementById(dateId);
        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 log results to the console';
    }

    function update_arrow_title() {
        let lu_arrow_up = document.getElementById(arrowId);
        if (!lu_arrow_up) return;
        let title = '';
        let next_items = lu_obj.items_with_soonest_assignments;
        let item_count = next_items ? next_items.length : 0;
        if (item_count>0) {
            let review_time = format_date_to_standard_output(next_items[0][scriptId].current_earliest_study_date, true, true);
            if (review_time === 'Now') {
                title = `${item_count} item${item_count===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, next_items));
                item_count = next_items.length;
                if (item_count>0) {
                    title += '\n\n';
                    review_time = format_date_to_standard_output(next_items[0][scriptId].current_earliest_study_date, true, true);
                }
            }
            if (item_count>0) {
                title += `The next ${item_count} item${item_count>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 && 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.classList.add('levelup-items');
        } else {
            lu_arrow_up.onclick = null;
            lu_arrow_up.classList.remove('levelup-items');
        }
    }

    function update_ui(element_id) {
        lu_obj.load_time = new Date();
        if (!element_id || element_id === dateId)
            update_date_title();
        if (!element_id || element_id === arrowId)
            update_arrow_title();
    }

    function setup_next_reviews_callback() {
        if (config.callback) {
            clearTimeout(config.callback);
            config.callback = null;
        }
        if (!lu_obj.items_with_soonest_assignments || lu_obj.items_with_soonest_assignments.length === 0) return;
        let time_diff = lu_obj.items_with_soonest_assignments[0][scriptId].current_earliest_study_date.getTime() - (new Date()).getTime();
        if (time_diff <= 0) return;
        config.callback = setTimeout(function() {
            config.callback = null;
            let log_enabled = config.log.enabled;
            config.log.enabled = false;
            fetch_items().then(() => config.log.enabled = log_enabled);
        }, time_diff);
    }

    function log_base_items_stats() {
        if (!config.log.enabled) return;
        const items_not_passed_by_type = get_items_by_index(get_not_passed_items(lu_obj.items), 'object', 'array');
        const get_item_name = itm => (itm.data.characters ? itm.data.characters : itm.data.slug);
        const get_item_stage = itm => (itm[scriptId].is_locked ? '🔒' : srs_stages[itm.assignments.srs_stage]);
        const get_next_study = itm => format_date_to_standard_output(itm[scriptId].current_earliest_study_date, true);
        const get_earliest_guru = itm => format_date_to_standard_output(itm[scriptId].earliest_guru_date, true);
        for (const itype of Object.keys(items_not_passed_by_type).sort((a,b)=>b.localeCompare(a))) {
            const not_passed_items = items_not_passed_by_type[itype];
            if (!not_passed_items || not_passed_items.length === 0) continue;
            const locked_items = Array.from(get_locked_items(not_passed_items));
            const str = [];
            if (config.log.detailed) {
                const output_table = not_passed_items.map(itm => ({
                    name: get_item_name(itm),
                    stage: get_item_stage(itm),
                    nextStudy: get_next_study(itm),
                    earliestGuru: get_earliest_guru(itm),
                    url: itm.data.document_url
                }));
                output_table.unshift({name:'Name', stage:'SRS Stage', nextStudy: 'Next Study Date', earliestGuru: 'Earliest Guru Date', url: 'URL'});
                const max_name_len = output_table.reduce((a,b)=>b.name.length > a ? b.name.length : a, 0) + 1;
                const max_stage_len = output_table.reduce((a,b)=>b.stage.length > a ? b.stage.length : a, 0) + 1;
                const max_study_len = output_table.reduce((a,b)=>b.nextStudy.length > a ? b.nextStudy.length : a, 0) + 1;
                const max_guru_len = output_table.reduce((a,b)=>b.earliestGuru.length > a ? b.earliestGuru.length : a, 0) + 1;
                for (const {name, stage, nextStudy, earliestGuru, url} of output_table) {
                    str.push(name.padEnd(max_name_len, ' ')+'\t'+
                             stage.padEnd(max_stage_len, ' ')+'\t'+
                             nextStudy.padEnd(max_study_len, ' ')+'\t'+
                             earliestGuru.padEnd(max_guru_len, ' ')+'\t'+
                             url);
                }
            }
            console.log('%s%s%o\n%s',
                `${not_passed_items.length} remaining ${itype}${itype==='radical'&&not_passed_items.length>1 ? 's' : ''} to guru`,
                locked_items.length > 0 ? ` (${locked_items.length} of which are still locked)` : '',
                not_passed_items,
                str.join('\n')
            );
        }
    }

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

    function get_text_for_icon_tooltip() {
        let items = lu_obj.items_not_passed_with_assignments_available;
        return (items && items.length > 0 ? `${items.length} item${items.length===1 ? '' : 's'} needed to level ${(items.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() === (new Date(0)).getTime()) return 'N/A';
        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_item(item) {
        if (!item) return;
        item[scriptId] = {is_locked: (!item.assignments || !item.assignments.unlocked_at)};
        item[scriptId].earliest_study_date = (!item[scriptId].is_locked ?
                                             new Date((item.assignments.started_at ?
                                                       item.assignments.available_at :
                                                       item.assignments.unlocked_at)) :
                                             get_earliest_unlock_date(item));
        item[scriptId].current_earliest_study_date = new Date(Math.max(item[scriptId].earliest_study_date.getTime(), lu_obj.load_time.getTime()));
        item[scriptId].earliest_guru_date = get_item_guru_date(item);
    }

    function add_dates_to_items(items) {
        for (const itm of items)
            add_dates_to_item(itm);
    }

    function* get_not_passed_items(items) {
        for (const itm of items)
            if (itm && (!itm.assignments || !itm.assignments.passed_at))
                yield itm;
    }

    function* get_not_locked_but_not_passed_items(items) {
        for (const itm of items)
            if (itm && itm.assignments && itm.assignments.unlocked_at && !itm.assignments.passed_at)
                yield itm;
    }

    function* get_not_passed_items_with_available_assignments(items) {
        for (const itm of items)
            if (itm && itm.assignments && itm.assignments.unlocked_at && !itm.assignments.passed_at && itm[scriptId].current_earliest_study_date.getTime()<=lu_obj.load_time.getTime())
                yield itm;
    }

    function* get_locked_items(items) {
        for (const itm of items)
            if (itm && (!itm.assignments || !itm.assignments.unlocked_at))
                yield itm;
    }

    function get_items_by_index(items, field, type) {
        const index = {};
        switch (type) {
            case 'single':
                for (const itm of items) {
                    if (itm[field] === undefined) { console.debug(`${itm}.${field} was undefined.`); continue; }
                    index[itm[field]] = itm;
                }
                break;
            case 'array':
            default:
                for (const itm of items) {
                    if (itm[field] === undefined) { console.debug(`${itm}.${field} was undefined.`); continue; }
                    if (index[itm[field]] === undefined) index[itm[field]] = [];
                    index[itm[field]].push(itm);
                }
                break;
        }
        return index;
    }

    function get_next_soonest_study_items(items) {
        return items.reduce((acc,itm)=>{
                let min_date = acc.length>0 ? acc[0][scriptId].current_earliest_study_date : itm[scriptId].current_earliest_study_date;
                if (itm[scriptId].current_earliest_study_date.getTime() > lu_obj.load_time.getTime() && itm[scriptId].current_earliest_study_date.getTime() < min_date.getTime()) {
                    min_date = itm[scriptId].current_earliest_study_date;
                    acc.length = 0;
                }
                if (itm[scriptId].current_earliest_study_date.getTime() <= lu_obj.load_time.getTime() || itm[scriptId].current_earliest_study_date.getTime() === min_date.getTime())
                    acc.push(itm);
                itm[scriptId].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) {
        if (!items_by_subject_id) return new Date(0);
        let min_date;
        for (const radIdx of item.data.component_subject_ids) {
            const rad = items_by_subject_id[radIdx];
            if (rad && rad[scriptId] && rad[scriptId].earliest_guru_date && (!min_date || rad[scriptId].earliest_guru_date.getTime() > min_date.getTime()))
                min_date = new Date(rad[scriptId].earliest_guru_date);
        }
        return min_date;
    }

    /**
     * Calculate item guru date
     * TODO: Double-check whether the next guru time should be rounded up to the nearest hour
     */
    function get_item_guru_date(item){
        let hours_to_guru = 0;
        if (!item || !item[scriptId] || !item[scriptId].current_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) || 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
        // Create a new date object so we don't modify the existing one
        const earliest_guru_date = (item[scriptId].current_earliest_study_date.getTime() > lu_obj.load_time.getTime() ?
            new Date(item[scriptId].current_earliest_study_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() {
        if (!items_by_type.kanji) return new Date(0);
        let kanji_items = items_by_type.kanji.sort((a,b)=>a[scriptId].earliest_guru_date.getTime()-b[scriptId].earliest_guru_date.getTime());
        return new Date(kanji_items[Math.ceil(kanji_items.length * 0.9)-1][scriptId].earliest_guru_date);
    }

})(window.level_up_assistant = {});