WaniKani Review Countdown

Adds a time limit to review questions.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name        WaniKani Review Countdown
// @description Adds a time limit to review questions.
// @version     1.2
// @license     MIT; http://opensource.org/licenses/MIT
// @match       https://www.wanikani.com/subjects/review
// @run-at      document-end
// @grant       none
// @namespace ajpazder
// ==/UserScript==

var countdown;
var settingsKey = 'wkfc_settings';
var settings = {
    timeLimitSeconds: 10,
    ignoredItemTypes: [], // May be "radical", "kanji", or "vocabulary"
    hideDecimal: false
};

loadCustomSettings();

addStyleRules();
addSettingsButton();
addSettingsForm();

onReviewItemChange(initializeCountdownTimer);


function loadCustomSettings() {
    var storedSettings = localStorage.getItem(settingsKey);
    if (!storedSettings) { return; }

    extend(settings, JSON.parse(storedSettings));
}

function extend(a, b) {
    for (var key in b) {
        if (b.hasOwnProperty(key)) {
            a[key] = b[key];
        }
    }
    return a;
}

function saveSettings() {
    localStorage.setItem(settingsKey, JSON.stringify(settings));
}

function addStyleRules() {
    var body = document.querySelector('body');
    body.innerHTML +=
        '<style type="text/css">' +
          '#countdown-settings-button { display: inline-block; background: #e1e1e1; padding: 8.5px; margin-right: 10px; border-top-left-radius: 4px; border-top-right-radius: 4px; }' +
          '#countdown-settings-button:hover { cursor: pointer; background: #d5d5d5; }' +
          '#countdown-settings { position: fixed; bottom: 55px; right: 108px; width: 200px; padding: 15px; border-radius: 5px; background: #fff; box-shadow: 2px 2px 2px rgba(0,0,0,.25); z-index: 100; }' +
          '@media(max-width: 768px) { #countdown-settings { right: 5px; } }' +
          '#countdown-settings::after { content: ""; width: 0; position: absolute; right: 20px; bottom: -25px; border-width: 25px 0 0px 20px; border-style: solid; border-color: #fff transparent; }' +
          '#countdown-settings input[type="number"] { width: 50px; border-radius: 4px; border: 1px solid #ccc; padding: 2px; }' +
          '#countdown-settings label.checkbox { display: block; margin-left: 15px; }' +
          '#countdown-settings label.checkbox input[type="checkbox"] { position: relative; top: 1px; }' +
        '</style>';
}

function addSettingsButton() {
    var hotkeys = document.querySelector('.quiz-footer__content');
    var settingsButtonHtml =
        '<div id="countdown-settings-button">⏱︎</div>';
    hotkeys.insertAdjacentHTML('beforebegin', settingsButtonHtml);

    setTimeout(function () {
        var settingsButton = document.getElementById('countdown-settings-button');
        settingsButton.onclick = function () {
            var settingsForm = document.getElementById('countdown-settings');
            if (!isHidden(settingsForm)) {
                hide(settingsForm);
            }
            else {
                show(settingsForm);
                var timeInput = settingsForm.querySelector('input[type="number"]');
                // We focus the input mainly so the settings form's keydown
                // handler will fire and close the form if escape is pressed.
                timeInput.focus();
                // We set the value here so that the cursor is at the end of
                // it when the input is focused.
                timeInput.value = settings.timeLimitSeconds;
            }
        };
    }, 50);
}

function show(element) {
    element.style.display = 'block';
}

function hide(element) {
    element.style.display = 'none';
}

function isHidden(element) {
    return element.style && element.style.display === 'none';
}

function addSettingsForm() {
    var settingsFormHtml =
        '<div id="countdown-settings" style="display: none;">' +
          '<h4 style="margin: 0;margin-bottom: 10px;">Countdown Settings</h4>' +
          '<div>' +
            '<label>Time: </label>' +
            '<input type="number" min="1" style="width: 50px;" /> seconds' +
          '</div>' +
          '<div>' +
            '<label>Display:</label>' +
            '<label class="checkbox"><input type="checkbox" class="hide-decimal"> Round to whole seconds</label>' +
          '</div>' +
          '<div>' +
            '<label>Ignore:</label>' +
            '<label class="checkbox"><input type="checkbox" class="ignore-item-type" value="radical"> Radicals</label>' +
            '<label class="checkbox"><input type="checkbox" class="ignore-item-type" value="kanji"> Kanji</label>' +
            '<label class="checkbox"><input type="checkbox" class="ignore-item-type" value="vocabulary"> Vocab</label>' +
          '</div>' +
        '</div>';
    var footer = document.querySelector('footer');
    footer.insertAdjacentHTML('beforebegin', settingsFormHtml);

    setTimeout(function () {
        var ignoreItemCheckboxes = document.querySelectorAll('#countdown-settings input[type="checkbox"].ignore-item-type');
        var ignoreItemCheckboxChangedEventHandler = function (event) {
            var checkboxValue = event.target.value;
            if (event.target.checked) {
                settings.ignoredItemTypes.push(checkboxValue);
            }
            else {
                var index = settings.ignoredItemTypes.indexOf(checkboxValue);
                settings.ignoredItemTypes.splice(index, 1);
            }
            saveSettings();
        };

        ignoreItemCheckboxes.forEach(function (checkbox) {
            checkbox.checked = settings.ignoredItemTypes.indexOf(checkbox.value) > -1;
            checkbox.onchange = ignoreItemCheckboxChangedEventHandler;
        });

        var hideDecimalCheckbox = document.querySelector('#countdown-settings input[type="checkbox"].hide-decimal');
        hideDecimalCheckbox.checked = settings.hideDecimal;
        hideDecimalCheckbox.onchange = function (event) {
            settings.hideDecimal = event.target.checked;
            saveSettings();
        };

        var timeLimitChangedEventHandler = function (event) {
            var inputValue = event.target.value;
            var minValue = parseInt(event.target.min);
            var saveValue = inputValue;
            if (saveValue < minValue) {
                saveValue = minValue;
                event.target.value = saveValue;
            }
            settings.timeLimitSeconds = saveValue;
            saveSettings();
        };

        var timeLimitInput = document.querySelector('#countdown-settings input[type="number"]');
        timeLimitInput.onchange = timeLimitChangedEventHandler;
        timeLimitInput.onkeyup = timeLimitChangedEventHandler;

        var settingsForm = document.getElementById('countdown-settings');
        settingsForm.keydown = function (event) {
            if (event.keyCode == 27) {
                hide(settingsForm);
            }
        };
    }, 50);
}

function initializeCountdownTimer() {
    if (isIgnoredItemType()) {
        // With the reorder script running, it's possible for
        // a countdown to be started on a not ignored item,
        // but continued on an ignored item when the reorder
        // script sorts items.  This aims to prevent that.
        clearInterval(countdown);
        var countdownContainer = document.querySelector('.countdown-container');
        if (countdownContainer) {
            countdownContainer.remove();
        }
    }
    else {
        startCountdown(settings.timeLimitSeconds);
    }
}

function onReviewItemChange(callback) {
	var observer = new MutationObserver(callback);
    var questionTypeContainer = document.querySelector('.quiz-input__question-type-container');
    observer.observe(questionTypeContainer, { attributes: true });
}

function isIgnoredItemType() {
    var currentItemType = document.querySelector('.quiz-input__question-category').innerText.toLowerCase();
    return settings.ignoredItemTypes.indexOf(currentItemType) !== -1;
}

function startCountdown(seconds) {
	// This function could potentially be called multiple times on
    // the same item so, just to be safe, we'll clear any existing
    // counter interval before we start a new one.
	clearInterval(countdown);

    var timeRemaining = seconds * 1000;
	var updateInterval = 100; // ms
	countdown = setInterval(function () {
		if (answerAlreadySubmitted()) {
			clearInterval(countdown);
			return;
		}

        var remainingSeconds = timeRemaining / 1000;
		var displayTime = settings.hideDecimal ? Math.ceil(remainingSeconds) : remainingSeconds.toFixed(1);
		updateCountdownDisplay(displayTime);

		if (timeRemaining <= 0) {
			clearInterval(countdown);
            if (answerFieldIsEmpty()) {
                enterWrongAnswer();
            }
            submitAnswer();
			return;
		}

		timeRemaining -= updateInterval;

	}, updateInterval);
}

function answerAlreadySubmitted() {
	return document.getElementById('user-response').getAttribute('enabled') === 'false';
}

function answerFieldIsEmpty() {
    return document.getElementById('user-response').value === '';
}

function enterWrongAnswer() {
	if (isReadingQuestion()) {
		setResponseTo('さっぱりわすれた');
	}
	else {
		setResponseTo('I… I don\'t know.');
	}
}

function isReadingQuestion() {
	return document.querySelector('.quiz-input__question-type-container').getAttribute('data-question-type') === 'reading';
}

function setResponseTo(value) {
	document.querySelector('.quiz-input__input').value = value;
}

function submitAnswer() {
	document.querySelector('.quiz-input__submit-button').click();
}

function updateCountdownDisplay(time) {
	// If this is only called once per question change, the counter doesn't show
	// for some reason.  There's probably some other JS running that overwrites it.
	if (!document.getElementById('countdown')) {
        var questionTypeLabel = document.querySelector('.quiz-input__question-type-container');
		questionTypeLabel.innerHTML += ' <span class="countdown-container">(<span id="countdown"></span>s)</span>';
	}

    var countdownTimerText = document.getElementById('countdown');
	countdownTimerText.innerText = time;
}