WaniKani Lock Script

Allows you to lock a set number of items from your review queue so you can keep on top of the rest.

La data de 10-07-2021. Vezi ultima versiune.

// ==UserScript==
// @name          WaniKani Lock Script
// @namespace     https://www.wanikani.com
// @author        Doncr
// @description   Allows you to lock a set number of items from your review queue so you can keep on top of the rest.
// @version       0.0.11
// @include       *://www.wanikani.com/*
// @grant         none
// @run-at        document-body
// ==/UserScript==

(function () {

    if (window.lockScriptInitialised) {
        return;
    }

    window.lockScriptInitialised = true;

    var localStorageConfigKey = "lockScriptCache";
    var localStorageAPIKeyKey = "apikeyv2";

    function getConfig() {

        var config = localStorage.getItem(localStorageConfigKey);

        if (config) {
            return JSON.parse(config);
        } else {
            return {
                availableAt: {},
                lockCount: 0,
            };
        }
    }

    function setConfig(config) {
        localStorage.setItem(localStorageConfigKey, JSON.stringify(config));
    }

    function getAPIKey() {
        return localStorage.getItem(localStorageAPIKeyKey);
    }

    function reloadCache(callback) {

        var apiKey = getAPIKey();

        if (apiKey.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)) {

            // Clear out any availability data from the cache.

            var config = getConfig();

            config.availableAt = {};
            delete config.availableUpdatedAt;

            setConfig(config);

            window.managedToUpdateLockCache = false;

            updateAssignmentsCache(true, callback);
        }
    }

    function getTextWidth(text, font) {
        var canvas = getTextWidth.canvas || (getTextWidth.canvas = document.createElement("canvas"));
        var context = canvas.getContext("2d");
        context.font = font;
        var metrics = context.measureText(text);
        return metrics.width;
    }

    function setAPIKey() {

        var apiKey = window.jQuery('#lockScriptAPIKey').val();

        if (apiKey.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)) {

            localStorage.setItem(localStorageAPIKeyKey, apiKey);

            reloadCache(function (err) {
                setLockCount(0);
            });

        } else if (apiKey.match(/^[0-9a-f]{32}$/)) {

            alert("It looks like you entered the API Version 1 key. You need to use the API Version 2 key.");

        } else {

            alert("Invalid API key format. You need to use the API Version 2 key.");
        }
    }

    function updateAssignmentsCache(async, callback) {

        var cache = getConfig();

        function allLoaded() {

            // Reduce lockCount if there aren't that many items left in the real review queue.

            var now = Date.now() / 1000;
            var realQueueSize = Object.values(cache.availableAt).filter(function (item) { return item.t < now; }).length;

            if (cache.lockCount > realQueueSize) {
                cache.lockCount = realQueueSize;
            }

            var availableCount = 0;

            Object.keys(cache.availableAt).forEach(function (key) {
                if (cache.availableAt[key].t < now) {
                    availableCount++;
                }
            });

            window.managedToUpdateLockCache = true;
            window.realQueueSize = realQueueSize;
            window.availableCount = availableCount;

            setLockCount(cache.lockCount);

            window.jQuery("#lsLoading").hide();
            window.jQuery("#lsLoaded").show();

            setConfig(cache);

            if (callback) {
                callback(null);
            }
        }

        function aux(uri) {

            window.jQuery.ajax({

                headers: { "Authorization": "Token token=" + getAPIKey() },
                dataType: "json",
                async: async,
                url: uri,

                complete: function (response, status) {

                    var json = response.responseJSON;

                    if (json.data_updated_at) {
                        cache.availableUpdatedAt = json.data_updated_at;
                    }

                    json.data.forEach(function (datum) {

                        var key = datum.data.subject_id.toString();
                        var avail = datum.data.available_at;
                        var srs_stage = datum.data.srs_stage;
                        var hidden = datum.data.hidden;

                        if ((avail != null) && (srs_stage > 0) && (srs_stage < 9) && (!hidden)) {
                            var value = Date.parse(avail) / 1000;
                            cache.availableAt[key] = { "t": value, "l": srs_stage };
                        } else {
                            delete cache.availableAt[key];
                        }
                    });

                    uri = json.pages.next_url;

                    if (uri) {
                        aux(uri);
                    } else {
                        allLoaded();
                    }
                }
            });
        }

        if (getAPIKey()) {

            var uri = "https://api.wanikani.com/v2/assignments";

            if (cache.availableUpdatedAt) {
                uri = uri + "?updated_after=" + cache.availableUpdatedAt;
            }

            window.jQuery("#lsLoading").show();
            window.jQuery("#lsLoaded").hide();

            if (window.managedToUpdateLockCache) {
                allLoaded();
            } else {
                aux(uri);
            }
        }
    }

    function modifyReviewQueue(queueText) {

        var queue = JSON.parse(queueText);

        const config = getConfig();
        const availableAt = config.availableAt;
        const lockCount = parseInt(config.lockCount);

        if (lockCount > 0) {

            var queueByTime = queue.map(function (item) { return item.id }).sort(function (a, b) {

                const sortMethod1 = availableAt[a].t - availableAt[b].t;

                if (sortMethod1 != 0) {
                    return sortMethod1;
                }

                return a - b;
            });

            queueByTime = queueByTime.slice(lockCount);

            var queueByTimeKeys = {};

            queueByTime.forEach(function (item) {
                queueByTimeKeys[item] = true;
            });

            queue = queue.filter(function (item) {
                return queueByTimeKeys[item.id.toString()];
            });

            window.lockScriptFilter = true;
            window.lockScriptLockCount = config.lockCount;
        }

        return JSON.stringify(queue);
    }

    const xhr = XMLHttpRequest.prototype;
    const originalResponseText = Object.getOwnPropertyDescriptor(xhr, 'responseText');

    Object.defineProperty(xhr, 'responseText', {

        get: function () {

            // Use the original responseText property accessor so we can get the
            // actual response from the server.

            var responseText = originalResponseText.get.apply(this);

            if (this.responseURL == "https://www.wanikani.com/review/queue") {
                updateAssignmentsCache(false);
                responseText = modifyReviewQueue(responseText);
            }

            return responseText;
        }
    });

    // The markup for the reviews summary page addition.

    function graphMarkup() {
        return `
            <style>
              #lockRangeInput {
                  padding: 0;
                  margin: 0;
                  height: calc(1em + 6px);
                  vertical-align: bottom;
                  max-width: 100%;
                  -webkit-appearance: none;
                  background: #d3d3d3;
                  outline: none;
              }

              #lockRangeInput::-webkit-slider-thumb {
                  -webkit-appearance: none;
                  appearance: none;
                  width: 25px;
                  height: 20px;
                  background: #444;
                  cursor: pointer;
              }

              #lockRangeInput::-ms-thumb {
                  appearance: none;
                  width: 25px;
                  height: 20px;
                  background: #444;
                  cursor: pointer;
              }

              #lockRangeInput::-moz-range-thumb {
                  border: none;
                  border-radius: 0;
                  width: 25px;
                  height: 20px;
                  background: #444;
                  cursor: pointer;
              }

              #lockRangeInput::-moz-range-track {
                  border-radius: 0;
                  height: 7px;
                  background: transparent;
              }

              #lockScriptSettings {
                  border: none;
                  background: none;
                  color: white;
                  padding: 4px 8px 4px 8px;
                  margin: -4px 0 -4px 0;
              }

              #lockScriptSettings:hover {
                  background: #666;
              }

              #lockScriptOptionsPane {
                  color: #666;
                  background: #ececec;
                  border: 2px solid #ddd;
                  border-radius: 2px;
                  padding: 0 2em 2em 2em;
                  margin-bottom: 1.2em;
              }

              #lockScriptOptionsPane .center {
                  text-align: center;
              }

              div#lsLoading {
                  color: #e44;
                  margin: 1.2em;
              }

              div#lsLoaded {
                  color: #2a2;
                  margin: 1.2em;
              }

              input#lockScriptAPIKey {
                  font-family: monospace !important;
              }

              #lockScriptOptionsPane .control-group {
                  -webkit-text-size-adjust: 100%
                  font-size: 14px
                  line-height: 20px
                  color: #333
                  font-family: "Ubuntu", Helvetica, Arial, sans-serif
                  margin-bottom: 10px;
              }

              #lockScriptOptionsPane .control-group-inner {
                  -webkit-text-size-adjust: 100%
                  font-size: 14px
                  line-height: 20px
                  color: #333
                  font-family: "Ubuntu", Helvetica, Arial, sans-serif
                  margin-top: 1rem;
              }

              #lockScriptOptionsPane hr {
                  -webkit-text-size-adjust: 100%;
                  font-size: 14px;
                  line-height: 20px;
                  color: #333;
                  font-family: "Ubuntu", Helvetica, Arial, sans-serif;
                  margin: 20px 0;
                  border: none;
                  border-top: 1px solid #ddd;
                  height: 0;
              }

              #lockScriptOptionsPane h3 {
                  -webkit-text-size-adjust: 100%;
                  color: inherit;
                  text-rendering: optimizelegibility;
                  line-height: 40px;
                  font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
                  font-weight: 300;
                  letter-spacing: -1px;
                  text-shadow: 0 1px 0 #fff;
                  font-size: 28px;
                  margin: 0 0 15px;
              }

              #lockScriptOptionsPane h1 {
                  -webkit-text-size-adjust: 100%;
                  margin: 10px 0;
                  margin-top: 0.6em !important;
                  color: inherit;
                  text-rendering: optimizelegibility;
                  line-height: 40px;
                  font-size: 38.5px;
                  font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
                  font-weight: 300;
                  letter-spacing: -1px;
                  text-shadow: 0 1px 0 #fff;
              }

              #lockScriptOptionsPane .control-group-inner label {
                  -webkit-text-size-adjust: 100%;
                  color: #333;
                  cursor: pointer;
                  font-size: 14px;
                  font-weight: normal;
                  line-height: 20px;
                  display: block;
                  margin-bottom: 5px;
                  font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
              }

              #lockScriptOptionsPane .lsInput {
                  -webkit-text-size-adjust: 100%;
                  margin: 0;
                  font-weight: normal;
                  margin-left: 0;
                  display: inline-block;
                  line-height: 20px;
                  vertical-align: middle;
                  background-color: #fff;
                  border: 1px solid #ccc;
                  transition: border linear 0.2s, box-shadow linear 0.2s;
                  flex-grow: 1;
                  margin-right: 8px;
                  font-family: Open Sans,Helvetica Neue,Helvetica,Arial,sans-serif;
                  --text-opacity: 1;
                  color: rgba(66,66,66,var(--text-opacity));
                  width: 100%;
                  box-sizing: border-box;
                  --border-opacity: 1;
                  border-color: rgba(189,189,189,var(--border-opacity));
                  border-radius: .25rem;
                  font-size: 1rem;
                  padding: 8px;
                  height: 40px;
                  box-shadow: inset 0 1px 2px 0 rgba(0,0,0,.06);
                  margin-bottom: 0;
              }

              #lockScriptOptionsPane .control-group-inner button {
                  -webkit-text-size-adjust: 100%;
                  margin: 0;
                  -webkit-appearance: button;
                  font-weight: normal;
                  display: inline-block;
                  padding: 4px 12px;
                  margin-bottom: 0;
                  font-size: 14px;
                  line-height: 20px;
                  text-align: center;
                  vertical-align: middle;
                  cursor: pointer;
                  text-shadow: 0 1px 1px rgba(255,255,255,0.75);
                  background-color: whitesmoke;
                  background-image: linear-gradient(to bottom, #fff, #e6e6e6);
                  background-repeat: repeat-x;
                  border-color: rgba(0,0,0,0.1) rgba(0,0,0,0.1) rgba(0,0,0,0.25);
                  border: 1px solid #ccc;
                  border-bottom-color: #b3b3b3;
                  border-radius: 4px;
                  box-shadow: inset 0 1px 0 rgba(255,255,255,0.2),0 1px 2px rgba(0,0,0,0.05);
                  color: #333;
                  font-family: "Ubuntu", Helvetica, Arial, sans-serif;
              }

              #lockScriptOptionsPane p {
                  -webkit-text-size-adjust: 100%;
                  color: #333;
                  margin: 0 0 10px;
                  font-family: "Ubuntu", Helvetica, Arial, sans-serif;
                  font-size: 16px;
                  line-height: 1.6em;
                  text-shadow: none;
              }

              input#review-queue-lock-count {
                  color: white;
                  background: black;
                  border: 0;
                  font-family: "Source Sans Pro", sans-serif;
                  padding: 0;
              }
            </style>

            <div class="pure-g-r">
              <div class="pure-u-1" id="incorrect" style="display: block;">
                <h2 style="background: #444"><span style="float: right"><button id="lockScriptSettings"><i class="icon-gear"></button></i></span><i class="icon-lock" style="margin: 0.14em"></i> Lock</h2>
                <div class="master">
                  <h3><span>Locked</span></h3>
                  <ul></ul>
                </div>
                <div>
                  <div class="apprentice active"><h3><span><strong id="lockScriptTitleNumber" title="Locked Items">0</strong> Locked</span></h3></div>
                  <div style="display: none" id="lockScriptOptionsPane">
                    <h1>Lock Script Settings</h1>
                    <h3>Personal Access Token</h3>
                    <p>
                      See <a href="https://www.wanikani.com/settings/personal_access_tokens">Account settings</a> to get a personal access token.
                      You can use the default read-only token because the Lock Script doesn't need to make any changes to your account.
                      Setting the token will erase cached data used by the Lock Script and so you can set the token again if you think that
                      the script is not behaving correctly.
                    </p>
                    <p>
                      Use the slider below to adjust the number of locked items once the API data has been loaded successfully.
                      Edit the lock count in the top-right corner directly to set it to a specific number.
                    </p>
                    <hr>
                    <div class="control-group">
                      <div class="control-group-inner">
                        <label for="lockScriptAPIKey">Personal Access Token</label>
                        <input class="lsInput" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" id="lockScriptAPIKey">
                      </div>
                    </div>
                    <hr>
                    <div class="control-group">
                      <div class="control-group-inner">
                        <button id="setAPIKey">Set Token</button>
                      </div>
                    </div>
                    <div class="center" id="lsLoading" style="display: none">Loading assignment data from API (Please wait...)</div>
                    <div class="center" id="lsLoaded" style="display: none">Successfully loaded data from API</div>
                  </div>
                  <div id="lsSlider" style="display: none">
                    <i class="icon-unlock" style="font-size: 1.2em; color: #a2a2a2; margin-right: 0.4em"></i>
                    <input class="slider" id="lockRangeInput" style="width: calc(100% - 4em)" type="range" min="0" max="48" value="0">
                    <i class="icon-lock" style="font-size: 1.2em; color: #a2a2a2; margin-left: 0.4em"></i>
                  </div>
                </div>
              </div>
            </div>`;
    }

    // This adds the current lock count during the review session.

    function addLockCount() {
        if (window.lockScriptFilter) {
            if (window.jQuery(".icon-lock").length == 0) {
                window.jQuery('#stats').prepend("<i class='icon-lock'></i><span>" + window.lockScriptLockCount + "</span>");
            }
        }
    }

    // This adds / updates elements in the reviews summary page.#

    function updateElements() {

        const config = getConfig();
        const numLocked = config.lockCount;
        const numUnlocked = window.availableCount - config.lockCount;

        window.jQuery('#lockRangeInput').val(config.lockCount);
        window.jQuery('#review-queue-count').text(numUnlocked);
        window.jQuery('#lockRangeInput').attr('max', window.realQueueSize);
        window.jQuery('#lockRangeInput').val(config.lockCount);
        window.jQuery('#lockScriptTitleNumber').text(config.lockCount);

        if (window.managedToUpdateLockCache) {
            window.jQuery("#lsSlider").show();
        }

        if (window.availableCount != undefined) {

            const width = getTextWidth(config.lockCount.toString(), "16px 'Source Sans Pro'");

            if (window.jQuery('#review-queue-lock-count').length == 0) {

               const markup = `
                    <span class="review-lock-count" style="background-color: black; color: white; display: inline-block; line-height: 3em; padding-left: 16px; padding-right: 2px; margin-right: -1px;">
                        <i style="margin-right: 8px" class="icon-lock"></i>
                        <input id="review-queue-lock-count"></input>
                    </span>
                `;

                window.jQuery('#review-queue-count').before(markup);
            }

            window.jQuery('#review-queue-lock-count').val(config.lockCount);
            window.jQuery('#review-queue-lock-count').css("width", width + "px");
        }
    }

    function setLockCount(newCountString) {

        var newCount = parseInt(newCountString);

        if (isNaN(newCount)) {
            newCount = 0;
        }

        if (newCount < 0) {
            newCount = 0;
        }

        if (newCount > window.realQueueSize) {
            newCount = window.realQueueSize;
        }

        var config = getConfig();
        config.lockCount = newCount;
        setConfig(config);

        updateElements();
    }

    document.body.addEventListener('DOMSubtreeModified', function (event) {

        var attrs = event.target.attributes;
        var pathname = window.location.pathname;

        if ((pathname == "/review") || (pathname == "/review/")) {

            if ((event.target.tagName == 'DIV') &&
                (attrs.class && attrs.class.value == 'review-stats-value')) {

                if (!window.lockMarkupAdded) {

                    window.lockMarkupAdded = true;

                    window.jQuery('#review-stats').parent('.pure-g-r').after(graphMarkup);
                    window.jQuery('#setAPIKey').click(setAPIKey);
                    window.jQuery('#reloadCache').click(reloadCache);
                    window.jQuery('#lockScriptAPIKey').val(getAPIKey());

                    updateAssignmentsCache(false);

                    if (window.managedToUpdateLockCache) {
                        updateElements();
                    } else {
                        window.jQuery('#lockScriptOptionsPane').show();
                    }

                    window.jQuery('#lockScriptSettings').on('click', function () {
                        window.jQuery('#lockScriptOptionsPane').toggle();
                    });

                    var slider = window.jQuery("#lockRangeInput");
                    var input = window.jQuery("#review-queue-lock-count");

                    slider.on("input", function () {
                        input.val(slider.val());
                        setLockCount(slider.val());
                    });

                    input.on("input", function () {
                        slider.val(input.val());
                        setLockCount(input.val());
                    });
                }
            }
        }

        if (pathname == "/review/session") {
            if ((event.target.tagName == 'SPAN') && (attrs.id && attrs.id.value == 'correct-rate')) {
                addLockCount();
            }
        }

    }, false);

})();