Greasy Fork is available in English.

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.

// ==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       1.1.4
// @match         https://www.wanikani.com/*
// @match         https://preview.wanikani.com/*
// @grant         GM_log
// @run-at        document-body
// @license       MIT
// @require       https://greasyfork.org/scripts/462049-wanikani-queue-manipulator/code/WaniKani%20Queue%20Manipulator.user.js?version=1426722


// ==/UserScript==

(async function () {

    if (window.lockScriptInitialised) {
        return;
    }

    window.lockScriptInitialised = true;

    const dashboardPaths = [
        "/",
        "/dashboard"
    ];

    const reviewSessionPaths = [
        "/subjects/review"
    ];

    const localStorageConfigKey = "lockScriptCache";
    const 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);
    }

    async function reloadCache() {

        const 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.

            let config = getConfig();

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

            setConfig(config);

            window.managedToUpdateLockCache = false;

            await updateAssignmentsCache();
        }
    }

    async function setAPIKey() {

        const apiKey = document.querySelector('#lockScriptAPIKey').value;

        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);

            await reloadCache();
            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.");
        }
    }

    async function updateAssignmentsCache() {

        let cache = getConfig();

        if (getAPIKey()) {

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

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

            if (document.querySelector("#lsLoading")) {
                document.querySelector("#lsLoading").style.display = "block";
                document.querySelector("#lsLoaded").style.display = "none";
            }

            while (uri) {

                const response = await fetch(uri, {
                    headers: {
                        "Authorization": "Token token=" + getAPIKey(),
                        "Accept": "application/json"
                    }
                });

                const json = JSON.parse(await response.text());

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

                json.data.forEach(function (datum) {

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

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

                uri = json.pages.next_url;
            }

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

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

            let 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);

            if (document.querySelector("#lsLoading")) {
                document.querySelector("#lsLoading").style.display = "none";
                document.querySelector("#lsLoaded").style.display = "block";
            }

            setConfig(cache);

            updateElements();
        }
    }

    function modifyReviewQueue() {

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

        const subjectTimeSortFunction = function (a, b) {

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

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

            return a - b;
        }

        if (lockCount > 0) {

            wkQueue.applyManipulation(function (queue) {

                let queueByTime = queue.map(item => item.id).sort(subjectTimeSortFunction);

                queueByTime = queueByTime.slice(lockCount);

                let queueByTimeKeys = {};

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

                queue = queue.filter(item => queueByTimeKeys[item.id.toString()]);

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

                return queue;
            });
        }
    }

    function graphMarkup() {
        return `

<section id="lockScriptPanel">
  <style>
    section#lockScriptPanel {
      grid-column-start: 1;
      grid-column-end: span 6;
    }

    div.lockScriptContent {
      color: var(--color-text);
      box-sizing: border-box;
      margin: 0;
      border: 0;
      font: inherit;
      vertical-align: baseline;
      padding: var(--spacing-normal) var(--spacing-tight);
      background-color: var(--color-wk-panel-content-background);
      border-radius: 5px;
    }

    #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: #666;
      float: right;
      display: inline;
      padding: 4px 8px 4px 8px;
      margin: -4px 0 -4px 0;
    }

    #lockScriptSettings:hover {
      background: #666;
      color: white;
    }

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

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

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

    button.lockScriptButton {
      appearance: button;
      background-color: rgb(245, 245, 245);
      background-image: linear-gradient(rgb(255, 255, 255), rgb(230, 230, 230));
      background-repeat: repeat-x;
      border-bottom-color: rgb(179, 179, 179);
      border-bottom-left-radius: 4px;
      border-bottom-right-radius: 4px;
      border-bottom-style: solid;
      border-bottom-width: 1px;
      border-image-outset: 0;
      border-image-repeat: stretch;
      border-image-slice: 100%;
      border-image-source: none;
      border-image-width: 1;
      border-left-color: rgb(204, 204, 204);
      border-left-style: solid;
      border-left-width: 1px;
      border-right-color: rgb(204, 204, 204);
      border-right-style: solid;
      border-right-width: 1px;
      border-top-color: rgb(204, 204, 204);
      border-top-left-radius: 4px;
      border-top-right-radius: 4px;
      border-top-style: solid;
      border-top-width: 1px;
      box-shadow: rgba(255, 255, 255, 0.2) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
      color: rgb(51, 51, 51);
      cursor: pointer;
      display: inline-block;
      font-family: "Ubuntu", Helvetica, Arial, sans-serif;
      font-size: 14px;
      font-weight: 400;
      line-height: 20px;
      height: 30px;
      margin-bottom: 0px;
      margin-left: 0px;
      margin-right: 0px;
      margin-top: 0px;
      padding-bottom: 4px;
      padding-left: 12px;
      padding-right: 12px;
      padding-top: 4px;
      text-align: center;
      text-shadow: rgba(255, 255, 255, 0.75) 0px 1px 1px;
      vertical-align: middle;
    }

    input.lockScriptInput {
      background-color: rgb(255, 255, 255);
      border-bottom-color: rgb(204, 204, 204);
      border-bottom-left-radius: 4px;
      border-bottom-right-radius: 4px;
      border-bottom-style: solid;
      border-bottom-width: 1px;
      border-image-outset: 0;
      border-image-repeat: stretch;
      border-image-slice: 100%;
      border-image-source: none;
      border-image-width: 1;
      border-left-color: rgb(204, 204, 204);
      border-left-style: solid;
      border-left-width: 1px;
      border-right-color: rgb(204, 204, 204);
      border-right-style: solid;
      border-right-width: 1px;
      border-top-color: rgb(204, 204, 204);
      border-top-left-radius: 4px;
      border-top-right-radius: 4px;
      border-top-style: solid;
      border-top-width: 1px;
      box-shadow: rgba(0, 0, 0, 0.075) 0px 1px 1px 0px inset;
      color: rgb(85, 85, 85);
      display: inline-block;
      font-family: "Ubuntu", Helvetica, Arial, sans-serif;
      font-size: 14px;
      font-weight: 400;
      height: 30px;
      line-height: 20px;
      margin-bottom: 10px;
      margin-left: 0px;
      margin-right: 0px;
      margin-top: 0px;
      padding-bottom: 4px;
      padding-left: 6px;
      padding-right: 6px;
      padding-top: 4px;
      transition-delay: 0s, 0s;
      transition-duration: 0.2s, 0.2s;
      transition-property: border, box-shadow;
      transition-timing-function: linear, linear;
      vertical-align: middle;
    }

  </style>
  <div class="wk-panel">
    <div class="wk-panel__header">
      <h2 class="wk-panel__title" style="width: 100%">
        <span>Lock Script</span>
        <button title="Lock Script Settings" id="lockScriptSettings" style="float: right">
          <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-gear-fill" viewBox="0 0 16 16">
            <path d="M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311c.446.82.023 1.841-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413 1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z"/>
          </svg>
        </button>
      </h2>
    </div>
    <div class="wk-panel__content">
      <div class="lockScriptContent">
        <div style="display: none" id="lockScriptOptionsPane" class="level-progress-dashboard__content">
          <h3 class="wk-title wk-title--small wk-title--underlined">Personal Access Token</h3>
          <p style="line-height: 20px; margin-bottom: 12px">
            See <a href="https://www.wanikani.com/settings/personal_access_tokens">API Tokens</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 style="line-height: 20px; margin-bottom: 12px">
            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="input-group m-t-1">
              <label class="control-label" for="lockScriptAPIKey">Personal Access Token</label>
              <div style="display: flex; margin-top: 4px;">
                <input placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" id="lockScriptAPIKey" type="text" class="lockScriptInput" style="flex: 1">
                <button id="setAPIKey" type="button" class="lockScriptButton" style="margin-left: 0.5em; flex: 0; white-space: nowrap">Set Token</button>
              </div>
            </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" class="level-progress-dashboard__content">
          <h3 id="lockedItemsHeader" class="wk-title wk-title--small wk-title--underlined">Locked Items</h3>
          <div>
            <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-unlock-fill" viewBox="0 0 16 16">
              <path d="M11 1a2 2 0 0 0-2 2v4a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h5V3a3 3 0 0 1 6 0v4a.5.5 0 0 1-1 0V3a2 2 0 0 0-2-2"/>
            </svg>
            <input class="slider" id="lockRangeInput" style="width: calc(100% - 4em)" type="range" min="0" max="48" value="0">
            <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-lock-fill" viewBox="0 0 16 16">
              <path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2m3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2"/>
            </svg>
          </div>
        </div>
      </div>
    </div>
  </div>
</section>
        `;
    }

    function updateElements() {

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

        if (document.querySelector("div.quiz-statistics")) {

            if (document.querySelector("#lockScriptQuizStatistic") === null) {

                const newElement = document.createElement("div");

                newElement.setAttribute("class", "quiz-statistics__item");
                newElement.setAttribute("title", "lock count");

                newElement.innerHTML = `
                  <div class="quiz-statistics__item-count">
                    <div class="quiz-statistics__item-count-icon">
                      <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-lock-fill" viewBox="0 0 16 16">
                        <path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2m3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2"/>
                      </svg>
                    </div>
                    <div class="quiz-statistics__item-count-text" id="lockScriptQuizStatistic">0</div>
                  </div>
                `;

                document.querySelector("div.quiz-statistics").prepend(newElement);
            }

            if (lockCount && (lockCount > 0)) {
                document.querySelector("#lockScriptQuizStatistic").innerText = lockCount;
            }
        }

        const progressAndForecast = document.querySelector("section.dashboard__level-progress");

        if (progressAndForecast) {

            if (document.querySelector("#lockScriptPanel") === null) {

                const newElement = document.createElement("section");

                newElement.setAttribute("id", "lockScriptPanel");
                newElement.innerHTML = graphMarkup();

                progressAndForecast.after(newElement);

                const lockScriptOptionsPane = document.querySelector("#lockScriptOptionsPane");
                const lsSlider = document.querySelector("#lsSlider");
                const slider = document.querySelector("#lockRangeInput");
                const input = document.querySelector("#review-queue-lock-count");

                document.querySelector("#lockScriptSettings").addEventListener('click', function (event) {
                    if (lockScriptOptionsPane.style.display === "none") {
                        lockScriptOptionsPane.style.display = "block";
                    } else {
                        lockScriptOptionsPane.style.display = "none";
                    }
                });

                document.querySelector('#setAPIKey').addEventListener("click", setAPIKey);

                if (getAPIKey()) {
                    document.querySelector('#lockScriptAPIKey').value = getAPIKey();
                }

                if (!getAPIKey()) {
                    lockScriptOptionsPane.style.display = "block";
                } else {
                    lockScriptOptionsPane.style.display = "none";
                }

                slider.addEventListener("input", function () {
                    setLockCount(slider.value);
                });
            }

            if (!getAPIKey()) {
                document.querySelector("#lsSlider").style.display = "none";
            } else {
                document.querySelector("#lsSlider").style.display = "block";
            }

            document.querySelector('#lockRangeInput').setAttribute('max', window.realQueueSize);
            document.querySelector('#lockRangeInput').value = config.lockCount;

            document.querySelector("#lockedItemsHeader").textContent =
                (config.lockCount === 0 ? "No" : config.lockCount) + " Locked Items";
        }
    }

    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.documentElement.addEventListener("turbo:load", async () => {

        if (reviewSessionPaths.indexOf(window.location.pathname) !== -1) {

            updateElements();
            await updateAssignmentsCache();

            modifyReviewQueue();
        }

        if (dashboardPaths.indexOf(window.location.pathname) !== -1) {

            updateElements();
            await updateAssignmentsCache();
        }
    });

})();