// ==UserScript== // @name WaniKani Review Countdown Timer // @namespace ajpazder // @description Adds a time limit to review questions. // @version 1.1.0 // @author Johnathon Pazder // @copyright 2016+, Johnathon Pazder // @license MIT; http://opensource.org/licenses/MIT // @include http*://www.wanikani.com/review/session* // @run-at document-end // @grant none // @downloadURL none // ==/UserScript== var countdown; var settingsKey = 'wkfc_settings'; var settings = { timeLimitSeconds: 10, ignoredItemTypes: [] // May be "radical", "kanji", or "vocabulary" }; initialize(); function initialize() { loadCustomSettings(); addStyleRules(); addSettingsButton(); addSettingsForm(); whenLoadingIndicatorIsHidden(function () { initializeCountdownTimer(); onReviewItemChange(initializeCountdownTimer); }); } function loadCustomSettings() { var storedSettings = localStorage.getItem(settingsKey); if (!storedSettings) return; settings = JSON.parse(storedSettings); } function saveSettings() { localStorage.setItem(settingsKey, JSON.stringify(settings)); } function addStyleRules() { $('body').append( '' ); } function addSettingsButton() { $('#hotkeys').before( '
' + '' + '
' ); setTimeout(function () { $('#countdown-settings-button').click(function () { var settingsForm = $('#countdown-settings'); if (settingsForm.is(':visible')) { settingsForm.hide(); } else { settingsForm.show(); var timeInput = settingsForm.find('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.val(settings.timeLimitSeconds); } }); }, 50); } function addSettingsForm() { $('#hotkeys').before( '' ); setTimeout(function () { $('#countdown-settings input[type="checkbox"]').each(function () { if (settings.ignoredItemTypes.indexOf($(this).val()) > -1) { $(this).prop('checked', true); } }); $('#countdown-settings input[type="number"]').on('change keyup', function () { var inputValue = $(this).val(); var minValue = parseInt($(this).prop('min')); var saveValue = inputValue; if (saveValue < minValue) { saveValue = minValue; $(this).val(saveValue); } settings.timeLimitSeconds = saveValue; saveSettings(); }); $('#countdown-settings input[type="checkbox"]').change(function() { var checkboxValue = $(this).val(); if ($(this).is(':checked')) { settings.ignoredItemTypes.push(checkboxValue); } else { var index = settings.ignoredItemTypes.indexOf(checkboxValue); settings.ignoredItemTypes.splice(index, 1); } saveSettings(); }); $('#countdown-settings').on('keydown', function (event) { if (event.keyCode == 27) { $(this).hide(); } }); }, 50); } function timeSettingChangedHandler() { } function whenLoadingIndicatorIsHidden(callback) { var target = document.getElementById('loading'); // Mutation observer will watch for change to visibilty. var observer = new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { // We assume that the loading indicator is hidden // when its style attribute is modified. if (mutation.attributeName === 'style') { callback(); return false; } }); }); observer.observe(target, { attributes: true }); } 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); $('#countdown').remove(); } else { startCountdown(settings.timeLimitSeconds); } } function onReviewItemChange(callback) { // currentItem seems to be updated even when switching // between reading and meaning for the same item. $.jStorage.listenKeyChange('currentItem', callback); } function isIgnoredItemType() { var isIgnored = false; settings.ignoredItemTypes.forEach(function (itemType) { var propertyName = itemType.substr(0, 3); if ($.jStorage.get('currentItem').hasOwnProperty(propertyName) ) { isIgnored = true; return false; } }); return isIgnored; } 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 displayTime = (timeRemaining / 1000).toFixed(1); updateCountdownDisplay(displayTime); if (timeRemaining === 0) { clearInterval(countdown); submitWrongAnswer(); return; } timeRemaining -= updateInterval; }, updateInterval); } function answerAlreadySubmitted() { return $("#user-response").is(":disabled"); } function submitWrongAnswer() { if (isReadingQuestion()) { setResponseTo('えと… 忘れた'); } else { setResponseTo('Umm… I forget.'); } submitAnswer(); } function isReadingQuestion() { return $('#question-type').hasClass('reading'); } function setResponseTo(value) { $('#answer-form input').val(value); } function submitAnswer() { $('#answer-form 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 ($('#countdown').length === 0) { $('#question-type h1').append(' (s)'); } $('#countdown').text(time); }