WaniKani Review Clock

Adds a clock to WaniKani review session statistics and estimates the remaining time.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name        WaniKani Review Clock
// @namespace   wkreviewclock
// @description Adds a clock to WaniKani review session statistics and estimates the remaining time.
// @match       http://www.wanikani.com/subjects/review
// @match       https://www.wanikani.com/subjects/review
// @match       http://www.wanikani.com/subjects/extra_study?queue_type=*
// @match       https://www.wanikani.com/subjects/extra_study?queue_type=*
// @version     1.5
// @author      Markus Tuominen
// @grant       none
// @license     GPL version 3 or later: http://www.gnu.org/copyleft/gpl.html
// @source      https://github.com/Markus98/wk-review-clock
// ==/UserScript==

let statHtmlElems;
let time;
let startTime;
let rateShowDelay;

const timerTimeKey = 'reviewTimerTime';
const timerRateKey = 'reviewTimerRate';
const averageStatsKey = 'reviewRateAverageStats';
const scriptId = 'WKReviewClock';

const defaultSettings = {
    units: 'rph',
    location: 'toprightright',
    showTimer: true,
    showRate: true,
    showRemaining: true,
    updateInterval: 1.0,
    enableRateShowDelay: false,
    rateShowDelay: 5,
    showTimeEstimate: true,
    averageIgnorePeriod: 3,
};

function splitToHourMinSec(timeSec) {
    const h = Math.floor( timeSec/60/60 );
    const min = Math.floor( (timeSec - (h*60*60)) / 60 );
    const sec = Math.round( timeSec - h*60*60 - min*60 );
    return {h, min, sec};
}

function getTimeString(hourMinSec, includeSec=true) {
    const { h, min, sec } = hourMinSec;

    const hourString = h ? h+'h ' : '';

    const minuteZero = h ? '0' : '';
    const minuteString = (h||min||!includeSec) ? (minuteZero+min).slice(-2)+'m ' : '';

    const secondZero = (h||min) ? '0' : '';
    const secondString = (secondZero+sec).slice(-2)+'s';

    return hourString + minuteString + (includeSec ? secondString : '');
}

function setCurrentTimerStats() {
    // settings
    let showTimer = defaultSettings.showTimer;
    let showRate = defaultSettings.showRate;
    let showRemaining = defaultSettings.showRemaining;
    let hideRateRemaining = false;
    if (window.wkof) {
        showTimer = wkof.settings[scriptId].showTimer;
        showRate = wkof.settings[scriptId].showRate;
        showRemaining = wkof.settings[scriptId].showRemaining;
        const enableRateShowDelay = wkof.settings[scriptId].enableRateShowDelay;
        hideRateRemaining = enableRateShowDelay && time<rateShowDelay;
    }

    const hourMinSec = splitToHourMinSec(time);
    if (showTimer) {
        statHtmlElems.timer.getLabel().textContent = getTimeString(hourMinSec);
    }

    const reviewsDoneNumber = parseInt(
        document.querySelector('[data-quiz-statistics-target="completeCount"]').textContent
    );
    const reviewRate = time !== 0 ? reviewsDoneNumber/time : 0; // reviews/sec
    if (showRate) {
        const formattedRate = formatRate(reviewRate, 'short');
        statHtmlElems.rate.getLabel().textContent = (hideRateRemaining ? '—' : formattedRate) + '';
    }

    const reviewsAvailableNumber = parseInt(
        document.querySelector('[data-quiz-statistics-target="remainingCount"]').textContent
    );
    const timeRemaining = reviewsAvailableNumber / reviewRate; // seconds
    if (showRemaining) {
        let remainingStr = 'Est. ';
        if (hideRateRemaining) {
            remainingStr += '—';
        } else if (Number.isFinite(timeRemaining)) {
            remainingStr += getTimeString(splitToHourMinSec(timeRemaining), false);
        } else {
            remainingStr += '∞';
        }
        statHtmlElems.remaining.getLabel().textContent = remainingStr;
    }

    // Set time and rate to localstorage for diplaying them later
    window.localStorage.setItem(timerTimeKey, time);
    window.localStorage.setItem(timerRateKey, reviewRate);
}

function generateStatHtmlElems() {
    function genStatDiv(title, iconClassName, idSuffix) {
        const statDiv = document.createElement('div');
        const statDivId = 'wk-review-clock-markus98_stat-div-' + idSuffix;
        statDiv.id = statDivId;
        statDiv.title = title;
        statDiv.className = 'quiz-statistics__item';
        const statCountDiv = document.createElement('div');
        statCountDiv.className = 'quiz-statistics__item-count';
        const statCountIconDiv = document.createElement('div');
        statCountIconDiv.className = 'quiz-statistics__item-count-icon';
        const statIcon = document.createElement('i');
        statIcon.className = iconClassName;
        const statLabelDiv = document.createElement('div');
        const labelId = 'wk-review-clock-markus98_label-' + idSuffix;
        statLabelDiv.id = labelId;
        statLabelDiv.className = 'quiz-statistics__item-count-text';
        statLabelDiv.style.cssText = 'white-space: nowrap;';

        statDiv.appendChild(statCountDiv);
        statCountDiv.appendChild(statCountIconDiv);
        statCountDiv.appendChild(statLabelDiv);
        statCountIconDiv.appendChild(statIcon);
        return {
            div: statDiv,
            getLabel: function () {
                return document.getElementById(labelId);
            },
            getDiv: function () {
                return document.getElementById(statDivId);
            },
        };
    }
    // Create statistics divs
    const timer = genStatDiv('elapsed time', 'fa fa-clock-o', 'timer');
    const rate = genStatDiv('review rate', 'fa fa-bolt', 'rate');
    const remaining = genStatDiv('estimated remaining time', 'fa fa-clock-o', 'remaining');

    statHtmlElems = {
        timer: timer,
        rate: rate,
        remaining: remaining,
        updateVisibility: function() {
            if (!window.wkof) return;
            const settings = wkof.settings[scriptId];
            if (settings) {
                const disp = (bool) => bool ? '' : 'display: none;';
                this.timer.getDiv().style.cssText = disp(settings.showTimer);
                this.rate.getDiv().style.cssText = disp(settings.showRate);
                this.remaining.getDiv().style.cssText = disp(settings.showRemaining);
            }
        }
    }

    // append divs to appropriate parent
    const location = window.wkof ? wkof.settings[scriptId].location : defaultSettings.location;
    if (location == 'toprightright') {
        const parent = document.getElementsByClassName('quiz-statistics')[0];
        parent.appendChild(timer.div);
        parent.appendChild(rate.div);
        parent.appendChild(remaining.div);
    } else if (location == 'toprightleft') {
        const parent = document.getElementsByClassName('quiz-statistics')[0];
        parent.prepend(remaining.div);
        parent.prepend(rate.div);
        parent.prepend(timer.div);
    } else if (location == 'bottom') {
        const parent = document.getElementById('additional-content');
        const bottomMenu = document.createElement('div');
        bottomMenu.style.cssText = 'display: flex; justify-content: center; padding: 10px;';
        bottomMenu.appendChild(timer.div);
        bottomMenu.appendChild(rate.div);
        bottomMenu.appendChild(remaining.div);
        bottomMenu.className = "additional-content__menu wkrc_bottom"
        parent.append(bottomMenu);
    }
    statHtmlElems.updateVisibility();
}

function setStatsAndUpdateTime() {
    time = (new Date() - startTime)/1000;
    setCurrentTimerStats();
}

function startTimer (intervalSec) {
    startTime = new Date();
    setStatsAndUpdateTime();
    setInterval(setStatsAndUpdateTime, intervalSec*1000);
}

function getAverageStats() {
    const statsObj = JSON.parse(localStorage.getItem(averageStatsKey));
    if (statsObj) {
        return statsObj;
    } else {
        // default
        return {
            rateSum: 0,
            reviews: 0,
            mostRecentAdded: false
        };
    }
}

function setAverageStats(statsObj) {
    localStorage.setItem(averageStatsKey, JSON.stringify(statsObj));
}

function setAverageRecentAdded(bool) {
    const stats = getAverageStats();
    stats.mostRecentAdded = bool;
    setAverageStats(stats);
}

function startReviewTimer() {
    // Start the timer
    const interval = window.wkof ? parseFloat(wkof.settings[scriptId].updateInterval) : defaultSettings.updateInterval;
    startTimer(interval);
    setAverageRecentAdded(false);
}

/**
 * @deprecated
 */
function showLastReviewStats() {
    const footer = document.getElementById('last-session-date');

    let ignoreInterval = defaultSettings.averageIgnorePeriod*60;
    let showEstimatedSessionTime = defaultSettings.showTimeEstimate;
    // Get settings if WK Open Framework is installed
    if (window.wkof) {
        ignoreInterval = parseFloat(wkof.settings[scriptId].averageIgnorePeriod)*60;
        showEstimatedSessionTime = wkof.settings[scriptId].showTimeEstimate;
    }

    // Create divs and spans for stats in footer
    const rateDiv = document.createElement('div');
    const timeDiv = document.createElement('div');
    const timeSpan = document.createElement('span');
    const rateSpan = document.createElement('span');
    timeDiv.appendChild(timeSpan);
    rateDiv.appendChild(rateSpan);
    const estimatedTimeDiv = document.createElement('div');
    estimatedTimeDiv.style.cssText = 'font-size: 0.6em; position: relative; top: -70%;';

    // Center text in review queue count
    const reviewCountSpan = document.getElementById('review-queue-count');
    reviewCountSpan.style.cssText += 'text-align: center;';
    
    // Reset button
    const resetAvgButton = document.createElement('button');
    resetAvgButton.textContent = 'reset average';
    resetAvgButton.style.cssText = 'font-size: 0.6em; color: inherit';
    resetAvgButton.onclick = () => {
        if (confirm('Are you sure you want to reset the average review rate?')) {
            localStorage.removeItem(averageStatsKey);
            location.reload();
        }
    };
    
    // Saved time and rate
    const lastTime = parseFloat(localStorage.getItem(timerTimeKey));
    const lastTimeStr = isNaN(lastTime) ? '—' : getTimeString(splitToHourMinSec(lastTime));
    const lastRate = parseFloat(localStorage.getItem(timerRateKey));
    const lastRateStr = formatRate(lastRate);

    // Average rate
    const avgStats = getAverageStats();
    if (!avgStats.mostRecentAdded && lastTime > ignoreInterval && lastRate > 0) {
        avgStats.rateSum += lastRate;
        avgStats.reviews += 1;
        avgStats.mostRecentAdded = true;
        setAverageStats(avgStats);
    }
    const avgRate = avgStats.rateSum / avgStats.reviews; // reviews/second
    const avgRateStr = formatRate(avgRate, 'short');

    // Estimate time for current reviews
    const numOfReviews = parseInt(reviewCountSpan.textContent);
    const estimatedTime = numOfReviews / avgRate;
    const estimatedTimeStr = getTimeString(splitToHourMinSec(estimatedTime), false);
    
    // Set stats text content
    timeSpan.textContent = `Duration: ${lastTimeStr}`;
    rateSpan.textContent = `Review rate: ${lastRateStr} (avg. ${avgRateStr}) (${avgStats.reviews} sessions)`;
    estimatedTimeDiv.textContent = 
        !showEstimatedSessionTime || isNaN(estimatedTime) || numOfReviews === 0 ? 
        '' : `~${estimatedTimeStr}`;

    // Append html elements to page
    footer.appendChild(timeDiv);
    footer.appendChild(rateDiv);
    footer.appendChild(resetAvgButton);
    reviewCountSpan.appendChild(estimatedTimeDiv);
}

let shortUnitNames = {'rph': 'r/h', 'rpm': 'r/m', 'mp100r': 'm/100r'}
let unitNames = {'rph': 'reviews/hr', 'rpm': 'reviews/min', 'mp100r': 'min/100 reviews'}
function formatRate(rps, format) {
    if (isNaN(rps) || rps < 0.00001) {
        return '—';
    }
    rps = parseFloat(rps);
    const units = window.wkof ? wkof.settings[scriptId].units : defaultSettings.units;
    let res;
    if (units == 'rph') {
        res = rps*3600;
    } else if (units == 'rpm') {
        res = rps*60;
    } else if (units == 'mp100r') {
        res = 1/rps/60*100;
    }
    if (format == 'short') {
        return res.toFixed(1) + ' ' + shortUnitNames[units];
    } else {
        return res.toFixed(1) + ' ' + unitNames[units];
    }

}

function openSettings() {
    var config = {
        script_id: scriptId,
        title: 'Review Clock Settings',
        on_save: () => {
            wkof.Settings.save(scriptId);
            statHtmlElems.updateVisibility();
            rateShowDelay = parseFloat(wkof.settings[scriptId].rateShowDelay)*60;
        },
        content: {
            general: {
                type: 'page',
                label: 'General',
                content: {
                    units: {
                        type: 'dropdown',
                        label: 'Units for Speed',
                        default: defaultSettings.units,
                        hover_tip: 'What units the review rate of completion should be displayed in.',
                        content: {
                            rph: 'reviews/hr',
                            rpm: 'reviews/min',
                            mp100r: 'min/100 reviews',
                        }
                    },
                }
            },
            reviewPage: {
                type: 'page',
                label: 'Review Page',
                content: {
                    location: {
                        type: 'dropdown',
                        label: 'Display Location',
                        default: defaultSettings.location,
                        hover_tip: 'Where to show the below items (if checked) during reviews.',
                        content: {
                            toprightright: 'top right (right of other stats)',
                            toprightleft: 'top right (left of other stats)',
                            bottom: 'bottom in gray font',
                        }
                    },
                    showTimer: {
                        type: 'checkbox',
                        label: 'Show elapsed time',
                        default: defaultSettings.showTimer,
                        hover_tip: 'Show the elapsed time during a review session.',
                    },
                    showRate: {
                        type: 'checkbox',
                        label: 'Show review rate',
                        default: defaultSettings.showRate,
                        hover_tip: 'Show the review rate (reviews/hour).',
                    },
                    showRemaining: {
                        type: 'checkbox',
                        label: 'Show remaining time estimate',
                        default: defaultSettings.showRemaining,
                        hover_tip: 'Show the estimated remaining time based on the review rate and remaining items.',
                    },
                    divider1: {
                        type: 'divider'
                    },
                    updateInterval: {
                        type: 'number',
                        label: 'Statistics update interval (s)',
                        hover_tip: 'How often the statistic numbers should be updated (x second intervals).',
                        default: defaultSettings.updateInterval,
                        min: 0.01
                    },
                    rateShowDelayGroup: {
                        type: 'group',
                        label: 'Estimate Show Delay',
                        content: {
                            rateShowDelaySection: {
                                type: 'html',
                                html: 'Only show the review rate and remaining time estimate after the session is longer than a specified duration.'
                            },
                            enableRateShowDelay: {
                                type: 'checkbox',
                                label: 'Enabled',
                                default: defaultSettings.enableRateShowDelay,
                                hover_tip: 'Enable a delay in showing the rate and time estimate.'
                            },
                            rateShowDelay: {
                                type: 'number',
                                label: 'Duration (min)',
                                hover_tip: 'The number of minutes that the review rate and time estimate should be hidden for at the beginning of a session.',
                                default: defaultSettings.rateShowDelay,
                                min: 0
                            }
                        }
                    }
                }
            },
        }
    }
    var dialog = new wkof.Settings(config);
    dialog.open();
}

function installSettingsMenu() {
    wkof.Menu.insert_script_link({
        name:      'review_clock_settings',
        submenu:   'Settings',
        title:     'Review Clock',
        on_click:  openSettings
    });
}

async function main() {
    if (window.wkof) {
        const wkof_modules = 'Settings,Menu';
        wkof.include(wkof_modules);
        await wkof.ready(wkof_modules)
            .then(() => wkof.Settings.load(scriptId, defaultSettings))
            .then(installSettingsMenu);
        rateShowDelay = parseFloat(wkof.settings[scriptId].rateShowDelay)*60;
    } else {
        console.warn('WaniKani Review Clock: Wanikani Open FrameWork required for adjusting settings. '
            + 'Installation instructions can be found here: https://community.wanikani.com/t/installing-wanikani-open-framework/28549');
    }

    const style = document.createElement('style');
    style.textContent = '.wkrc_bottom i { margin-right: 0.5em; margin-left: 0.8em; }' +
        '.wkrc_bottom span { margin-right: 0.5em; }' +
        '.wkrc_bottom { color:#BBB; letter-spacing: initial; display: block; text-align: center; }';
    document.head.append(style);

    if(/subjects\/(review|extra_study\?queue_type=.*)$/.exec(window.location.href)) { // review page
        await generateStatHtmlElems();
        startReviewTimer();
    } else { // review summary page
        // showLastReviewStats();
    }
}

main();