Wanikani Wrap-up Button Enhancement (Jerky Edition)

Beefed-up Wrap-up button (Jerky Edition)

// ==UserScript==
// @name         Wanikani Wrap-up Button Enhancement (Jerky Edition)
// @namespace    https://www.wanikani.com
// @version      5.0.3
// @description  Beefed-up Wrap-up button (Jerky Edition)
// @author       Inserio (Orig. Mempo)
// @match        https://www.wanikani.com/*
// @grant        none
// @license      MIT
// ==/UserScript==
/* global Stimulus */
/* jshint esversion: 11 */

(function() {
    'use strict';

    // ========================================================================
    // Globals
    const scriptId = 'wrap-up-amount';
    const menuId = `${scriptId}-menu`;
    const filterId = `${scriptId}-filter`;
    const filterContainerId = `${filterId}-container`;
    const filterIconId = `${filterId}-icon`;
    const filterIconContainerId = `${filterIconId}-container`;
    const inputNumberId = `${scriptId}-input`;
    const inputNumberContainerId = `${inputNumberId}-container`;
    const defaultAmount = 10; // by default
    const state = {
        queue: {
            controller: null,
            count: defaultAmount
        },
        filter: {
            enabled: false,
            hasProcessed: false
        }
    };

    // ========================================================================
    // Startup

    installCSS();
    document.documentElement.addEventListener('turbo:load', () => {
        setTimeout(() => {
            if (!(initUi())) return;
            resize_buttons();
        }, 0);
    });

    // ========================================================================
    // Functions

    /**
     * Install stylesheet.
     */
    function installCSS() {
        // language=CSS
        const css = `
li#${menuId} {
    display: grid;
    grid-template-columns: 1fr 1fr;
    grid-template-rows: 1fr;
    align-items: center;
    align-content: center;
}
li#${menuId} * {
    text-align: center;
}
li#${menuId} div {
    border: none;
    flex: 0 1 min-content;
    outline: none;
    text-decoration: none;
}
#${inputNumberId} {
    box-sizing:border-box;
    flex: 1 1 min-content;
    width: 0;
    padding: 0px;
    margin: 0px;
}
`;
        const head = document.getElementsByTagName('head')[0];
        if (head) {
            const style = document.createElement('style');
            style.setAttribute('id', scriptId);
            style.setAttribute('type', 'text/css');
            style.textContent = css;
            head.insertAdjacentElement('beforeend', style);
        }
    }

    /**
     * Initialize the user interface.
     */
    async function initUi() {
        state.queue.controller = null;
        const wrapUpBox = document.getElementById('additional-content')?.querySelector('li:has(.additional-content__item--wrap-up)');
        if (!wrapUpBox) return false;

        const elementsText = `
<li id="${menuId}" class="additional-content__menu-item additional-content__menu-item--5">
    <a class="additional-content__item additional-content__item--wrap-up-filter" id="${filterContainerId}" title="Filter Wrap Up to Only Started Items" data-wrap-up-count-class="additional-content__item-icon-text" data-wrap-up-active-class="additional-content__item--active" tabindex="0">
        <div class="additional-content__item-text">Filter</div>
        <div class="additional-content__item-icon-container" id="${filterIconContainerId}">
            <div class="additional-content__item-icon-text"></div>
            <div class="wk-icon wk-icon--scales" id="${filterIconId}" aria-hidden="true">🔗</div>
        </div>
    </a>
    <a class="additional-content__item additional-content__item--wrap-up-count" id="${inputNumberContainerId}" title="Wrap Up Item Count" tabindex="0">
        <input class="additional-content__item--wra" id="${inputNumberId}" type="number" min="1" value="${state.queue.count}">
    </a>
</li>`;
        wrapUpBox.insertAdjacentHTML('afterend', elementsText);
        const inputNumber = document.getElementById(inputNumberId);
        inputNumber.addEventListener('input', function(event) {
            onWrapUpStartedOrValueChanged(event.target);
        });
        const filterContainer = document.getElementById(filterContainerId);
        filterContainer.addEventListener('click', function(event) {
            switchFilterToggleState();
        });

        waitForController('quizQueue', 'quiz-queue').then(res=>{
            state.queue.controller = res;
            updateQueueCount(state.queue.count);
            registerOnWrapUpListener();
        });
        return true;
    }

    // ------------------------------------------------------------------------
    // Resize the buttons according to how many are visible.
    // ------------------------------------------------------------------------
    function resize_buttons() {
        const additional_content = document.getElementById('additional-content')
        if (!additional_content) return;
        const buttons = Array.from(additional_content.querySelectorAll('.additional-content__menu-item'));
        const visible_buttons = buttons.filter((elem)=>!elem.matches('.hidden,[hidden]'));
        const btn_count = visible_buttons.length;
        for (const btn of visible_buttons) {
            const percent = Math.floor(10000/btn_count)/100 + '%';
            btn.style.width = `calc(${percent} - 10px)`;
            btn.style.flex = `0 0 calc(${percent} - 10px)`;
            btn.style.marginRight = '10px';
        }
        visible_buttons.slice(-1)[0].style.marginRight = '0px';
    }

    function registerOnWrapUpListener() {
        let onRegistration = ({toggleWrap, deregisterObserver}) => {};
        let onUpdateCount = ({currentCount}) => {};
        let onWrapUp = ({isWrappingUp, currentCount}) => {
            if (isWrappingUp) {
                onWrapUpFilterClicked(state.filter.enabled);
                if (!state.filter.hasProcessed) {
                    // don't use the queue count if using the filter
                    onWrapUpStartedOrValueChanged(document.getElementById(inputNumberId));
                }
            }
        };
        let registerWrapUpObserver = {
            onRegistration: onRegistration,
            onUpdateCount: onUpdateCount,
            onWrapUp: onWrapUp
        };
        window.dispatchEvent(new CustomEvent('registerWrapUpObserver', {detail: {observer: registerWrapUpObserver}}));
    }

    function sleep(ms=0) { return new Promise(resolve => setTimeout(resolve, ms)); }
    function getControllerV1(name) { return Stimulus.controllers.find(controller => controller[name]); }
    function getControllerV2(name) { return Stimulus.getControllerForElementAndIdentifier(document.querySelector(`[data-controller~="${name}"]`),name); }

    async function waitForController(nameV1, nameV2) {
        let controller = (nameV1 ? getControllerV1(nameV1) : null) ?? (nameV2 ? getControllerV2(nameV2) : null);
        if (controller) return controller;
        await sleep(1);
        return await waitForController(nameV1, nameV2);
    }

    function switchFilterToggleState(forceState) {
        const newState = document.getElementById(filterContainerId).classList.toggle('additional-content__item--active', forceState);
        state.filter.enabled = newState;
        document.getElementById(inputNumberContainerId).classList.toggle('additional-content__item--disabled', newState);
        document.getElementById(inputNumberId).disabled = newState;
    }

    function onWrapUpStartedOrValueChanged(element) {
        const newQueueSize = getCustomWrapUpAmount(element);
        if (newQueueSize === null) {
            element.value = state.queue.count;
            return;
        }
        updateQueueCount(newQueueSize);
    }

    function onWrapUpFilterClicked(newState) {
        const quizQueue = state.queue.controller?.quizQueue;
        if (!quizQueue) return;
        if (!quizQueue.wrapUpManager.wrappingUp) // don't actually modify the queue if not currently wrappingUp
            return;
        if (!newState) {
            const emptySlots = quizQueue.maxActiveQueueSize - quizQueue.activeQueue.length;
            quizQueue.activeQueue = quizQueue.activeQueue.concat(quizQueue.backlogQueue.slice(0, emptySlots));
            quizQueue.backlogQueue = quizQueue.backlogQueue.slice(emptySlots);
            quizQueue.fetchMoreItems();
            quizQueue.wrapUpManager.updateQueueSize(quizQueue.activeQueue.length);
            state.filter.hasProcessed = false;
            return;
        }
        const ids = Array.from(quizQueue.stats.data.entries().filter(([,{reading,meaning}])=>(!reading.complete && meaning.complete) || (reading.complete && !meaning.complete)).map(([id])=>id));
        const newActiveQueue = [];
        const newBacklogQueue = [];
        for (const item of quizQueue.activeQueue) {
            if (ids.includes(item.id))
                newActiveQueue.push(item);
            else
                newBacklogQueue.push(item);
        }
        if (!newActiveQueue.includes(quizQueue.currentItem)) newActiveQueue.unshift(quizQueue.currentItem);
        newBacklogQueue.push(quizQueue.backlogQueue);
        quizQueue.activeQueue = newActiveQueue;
        quizQueue.backlogQueue = newBacklogQueue;
        quizQueue.wrapUpManager.updateQueueSize(quizQueue.activeQueue.length);
        state.filter.hasProcessed = true;
    }

    function getCustomWrapUpAmount(element) {
        if (!element || !element.value) return null;
        let amount = Number(element.value);
        if (Number.isNaN(amount) || (amount = parseInt(amount)) <= 0) return null;
        return state.queue.count = amount;
    }

    function updateQueueCount(newSize) {
        if (typeof newSize !== 'number') return;
        const quizQueue = state.queue.controller?.quizQueue;
        if (!quizQueue) return;
        if (newSize > quizQueue.totalItems)
            document.getElementById(inputNumberId).value = newSize = quizQueue.totalItems;
        if (!quizQueue.wrapUpManager.wrappingUp) return; // don't actually modify the queue if not currently wrappingUp
        if (state.filter.hasProcessed) return; // this shouldn't be necessary, but better to be safe
        const queueDifference = newSize - quizQueue.maxActiveQueueSize;
        if (queueDifference === 0) return;
        // update the queue similar to how it is done in `onWrapUp({isWrappingUp})`
        // empty slots must be calculated because a user could have previously been in wrap up mode
        const emptySlots = quizQueue.maxActiveQueueSize - quizQueue.activeQueue.length;

        quizQueue.maxActiveQueueSize = newSize;
        let sliceIndex;
        if (queueDifference > 0) {
            sliceIndex = emptySlots + queueDifference;
            quizQueue.activeQueue = quizQueue.activeQueue.concat(quizQueue.backlogQueue.slice(0, sliceIndex));
            quizQueue.backlogQueue = quizQueue.backlogQueue.slice(sliceIndex);
            quizQueue.fetchMoreItems();
        } else {
            sliceIndex = quizQueue.maxActiveQueueSize - emptySlots;
            quizQueue.backlogQueue = quizQueue.activeQueue.slice(sliceIndex).concat(quizQueue.backlogQueue);
            quizQueue.activeQueue = quizQueue.activeQueue.slice(0, sliceIndex);
        }
        quizQueue.wrapUpManager.updateQueueSize(quizQueue.activeQueue.length);
    }

})();