Show Total Lesson Count - WaniKani

Add the count of total lessons to the Today's Lessons widget

当前为 2025-10-23 提交的版本,查看 最新版本

// ==UserScript==
// @name         Show Total Lesson Count - WaniKani
// @namespace    https://codeberg.org/lupomikti
// @version      0.6.0
// @description  Add the count of total lessons to the Today's Lessons widget
// @license      MIT
// @author       LupoMikti
// @match        https://www.wanikani.com/*
// @grant        none
// @supportURL   https://github.com/lupomikti/Userscripts/issues
// ==/UserScript==

// Additional supportURL: https://community.wanikani.com/t/userscript-show-total-lesson-count/66776

(async function () {
    'use strict';

    /* global wkof */

    const scriptId = 'show_total_lesson_count';
    const scriptName = 'Show Total Lesson Count';

    const globalState = {
        initialLoad: true,
        stateStarting: false,
        todaysLessonsFrameLoaded: false,
        navBarCountFrameLoaded: false,
        hasOutputLog: false,
        turboEventBusy: false,
        mainRetryCounter: 4,
    };

    let debugLogText = `START: ${scriptName} Debug Log:\n`;
    let mainSource = '';
    const INTERNAL_DEBUG_TURBO_HANDLING = false;

    let todaysLessonsCount;
    let settings;

    function addToDebugLog(message) {
        debugLogText += `${new Date().toISOString()}: ${message}\n`;
    }

    function printDebugLog(force = false) {
        if (!globalState.hasOutputLog || force) {
            console.log(`${scriptName}: Outputting a debug log to console.debug()\nTo disable this setting, open "Settings > ${scriptName}" and toggle "Enable console debugging" off`);
            console.debug(debugLogText);
        }
        if (!force) globalState.hasOutputLog = true;
        debugLogText = `START: ${scriptName} Debug Log:\n`;
    }

    if (!window.wkof) {
        if (confirm(scriptName + ' requires Wanikani Open Framework.\nDo you want to be forwarded to the installation instructions?')) {
            window.location.href = 'https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549';
        }
        return;
    }

    const wkofTurboEventsScriptUrl = 'https://update.greasyfork.org/scripts/501980/1426289/Wanikani%20Open%20Framework%20Turbo%20Events.user.js';
    addToDebugLog(`Attempting to load the TurboEvents library script...`)
    await wkof.load_script(wkofTurboEventsScriptUrl, /* use_cache */ true);
    addToDebugLog(`Checking if TurboEvents library script is loaded in...`)
    let injectedDependency = document.head.querySelector('script[uid*="Turbo"]');
    addToDebugLog(`Turbo Events library ${injectedDependency ? 'is' : 'is NOT'} loaded.`);

    if (INTERNAL_DEBUG_TURBO_HANDLING) {
        window.addEventListener('turbo:load', () => { console.log(`DEBUG: turbo:load has fired`); });
        window.addEventListener('turbo:before-frame-render', (e) => { console.log(`DEBUG: turbo:before-frame-render has fired for '#${e.target.id}'`); });
        window.addEventListener('turbo:frame-load', (e) => { console.log(`DEBUG: turbo:frame-load has fired for '#${e.target.id}'`); });
    }

    const _init = async (source) => {
        if (globalState.stateStarting) { addToDebugLog(`SOURCE = "${source}" | We are already in the starting state, no need to initialize, returning...`); return; }
        addToDebugLog(`SOURCE = "${source}" | Setting global state and calling _start()`);
        globalState.initialLoad = globalState.stateStarting = true;
        globalState.hasOutputLog = globalState.todaysLessonsFrameLoaded = false;
        await _start();
    };

    wkof.ready('TurboEvents').then(() => {
        addToDebugLog(`Start of TurboEvents ready callback`);

        const urlList = [
            wkof.turbo.common.locations.dashboard,
            wkof.turbo.common.locations.items_pages,
            // vvvv Any page with the nav bar that's not one of the above locations vvvv
            /^https:\/\/www\.wanikani\.com\/(settings|level|radicals|kanji|vocabulary)(\/|\?difficulty=).+\/?$/,
        ];

        wkof.turbo.events.load.addListener(async (e) => {
            globalState.turboEventBusy = true;
            await _init('turbo:load').then(() => { globalState.turboEventBusy = false });
        }, { urls: urlList, passive: true });

        wkof.turbo.events.before_frame_render.addListener(async (e) => {
            globalState.turboEventBusy = true;
            const frameId = e.target.id;
            addToDebugLog(`turbo:before-frame-render has fired for "#${frameId}"`);
            if (globalState.initialLoad && !globalState.stateStarting) {
                addToDebugLog(`globalState.initialLoad is true (no frames were previously retrieved) and we are not already in starting state, starting initialization sequence...`);
                await _init('turbo:before-frame-render');
                return;
            }
            if (frameId === 'todays-lessons-frame') {
                globalState.todaysLessonsFrameLoaded = false;
            }
            else if (frameId === 'lesson-and-review-count-frame') {
                globalState.navBarCountFrameLoaded = false;
            }
        }, { urls: urlList, passive: true });

        wkof.turbo.events.frame_load.addListener(async (e) => {
            const frameId = e.target.id;
            addToDebugLog(`turbo:frame-load was fired for "#${frameId}", ${['todays-lessons-frame', 'lesson-and-review-count-frame'].includes(frameId) ? 'calling main function' : 'doing nothing...'}`);
            if (!globalState.stateStarting) {
                addToDebugLog(`DETOUR - turbo:frame-load was fired before we could begin starting or after main fully finished before frame events fired; changing following invocations of main() to _start() if we are not 'doing nothing'`);
            }
            mainSource = `turbo:frame-load for "#${frameId}"`;
            if (frameId === 'todays-lessons-frame') {
                globalState.todaysLessonsFrameLoaded = true;
                if (!globalState.stateStarting) { globalState.stateStarting = true; await _start(); return; }
                await main();
            }
            else if (frameId === 'lesson-and-review-count-frame') {
                globalState.navBarCountFrameLoaded = true;
                if (!globalState.stateStarting) { globalState.stateStarting = true; await _start(); return; }
                await main();
            }
            mainSource = '';
            globalState.turboEventBusy = false;
        }, { urls: urlList, passive: true });

        addToDebugLog(`All turbo callbacks have been sent to TurboEvents library to be registered`);
    }).catch((err) => { addToDebugLog(`TurboEvents library rejected with error: ${err}`); })
        .finally(() => {
            if (INTERNAL_DEBUG_TURBO_HANDLING) {
                addToDebugLog(`SOURCE = "turbo ready finally"`);
                printDebugLog(INTERNAL_DEBUG_TURBO_HANDLING);
            }
            _init(`wkof.ready('TurboEvents') finally callback`);
        });

    async function _start() {
        addToDebugLog(`Starting...`);
        wkof.include('Settings, Menu, Apiv2');
        await wkof.ready('Settings, Menu, Apiv2').then(loadSettings).then(insertMenu).then(insertStylesheet).then(main)
            .catch((err) => { addToDebugLog(`wkof.ready('Settings, Menu, Apiv2') rejected (or callbacks threw an exception) with error: ${err}`); })
            .finally(() => { if (INTERNAL_DEBUG_TURBO_HANDLING) { addToDebugLog(`SOURCE = "wkof modules ready finally"`); printDebugLog(INTERNAL_DEBUG_TURBO_HANDLING); } });
    }

    function loadSettings() {
        addToDebugLog(`Loading settings...`);

        let defaults = {
            showTotalOnly: false,
            enableDebugging: true,
        };

        return wkof.Settings.load(scriptId, defaults).then(function (wkof_settings) { settings = wkof_settings; });
    }

    function insertMenu() {
        addToDebugLog(`Inserting menu...`);

        let config = {
            name: scriptId,
            submenu: 'Settings',
            title: scriptName,
            on_click: openSettings
        };

        wkof.Menu.insert_script_link(config);
        mainSource = `_start() -> loadSettings() -> insertMenu()`;
    }

    async function saveSettings(wkof_settings) {
        globalState.hasOutputLog = false;
        addToDebugLog(`Save button was clicked on settings, calling main() with new settings...`);
        mainSource = 'wkof.Settings.save()';
        settings = wkof_settings;
        await main();
        mainSource = '';
    }

    function openSettings() {
        let config = {
            script_id: scriptId,
            title: scriptName,
            on_save: saveSettings,
            content: {
                showTotalOnly: {
                    type: 'checkbox',
                    label: 'Show Only Total Lesson Count',
                    hover_tip: `Changes display between "<today's lesson count> / <total lesson count>" and just "<total lesson count>"`,
                    default: false,
                },
                enableDebugging: {
                    type: 'checkbox',
                    label: 'Enable console debugging',
                    hover_tip: `Enable output of debugging info to console.debug()`,
                    default: true,
                }
            }
        };

        let dialog = new wkof.Settings(config);
        dialog.open();
    }

    function insertStylesheet() {
        const css = `
.todays-lessons-widget__title-container:has(.todays-lessons-widget__title-group-container) {
  display: inline-flex;
  gap: var(--spacing-normal);
  justify-content: space-evenly;
}

.todays-lessons-widget__title-group-container {
  display: flex;
  flex-direction: column;
}

.todays-lessons-widget__title-group-container .todays-lessons-widget__subtitle {
  margin-top: 2px;
}

.todays-lessons-widget__title-container:has(.todays-lessons-widget__title-group-container) + .todays-lessons-widget__text {
  align-self: center;
}
`;
        if (document.getElementById('total-lesson-count-style') === null) {
            let styleSheet = document.createElement('style');
            styleSheet.id = 'total-lesson-count-style';
            styleSheet.textContent = css;
            document.head.appendChild(styleSheet);
        }
    }

    function getCountContainer() {
        let dashboardTileCountContainer = document.querySelector('.todays-lessons-widget__count-text .count-bubble');

        if (globalState.initialLoad && (dashboardTileCountContainer)) {
            let container = dashboardTileCountContainer;
            todaysLessonsCount = parseInt(container.textContent);
            globalState.initialLoad = false;
        }

        return dashboardTileCountContainer;
    }

    async function main() {
        addToDebugLog(`Main function is executing... source of start = [${mainSource}]`);

        if (!settings) {
            addToDebugLog(`We do not have settings, setting timeout on _start()`);
            if (!globalState.stateStarting) { setTimeout(_start, 50); }
            else addToDebugLog(`Did not set timeout due to already being in starting state`);
            addToDebugLog(`Main function is returning (source = [${mainSource}])`);
            return;
        }
        addToDebugLog(`We have settings`);

        addToDebugLog(`Retrieving summary data via await of the endpoint...`);
        let summary_data = await wkof.Apiv2.get_endpoint('summary');
        addToDebugLog(`Summary data has been retrieved`);

        if (!globalState.stateStarting && globalState.hasOutputLog) {
            globalState.hasOutputLog = false;
            addToDebugLog(`Main function successfully executed beforehand, preventing repeat execution...`);
            if (settings.enableDebugging) printDebugLog(INTERNAL_DEBUG_TURBO_HANDLING);
            return;
        }

        let totalLessonCount = summary_data.lessons[0].subject_ids.length;
        let lessonCountContainer;

        if (globalState.todaysLessonsFrameLoaded) {
            addToDebugLog(`Frame loaded, retrieving container in frame containing the count...`);
            lessonCountContainer = getCountContainer();
            addToDebugLog(`Count container has been retrieved`);
        }
        else {
            addToDebugLog(`No frames loaded, checking to see if all listened-to turbo events have settled...`);
            addToDebugLog(`starting = ${globalState.stateStarting}, turboEventBusy = ${globalState.turboEventBusy}, retries = ${globalState.mainRetryCounter}`)
            if (globalState.stateStarting && !globalState.turboEventBusy && globalState.mainRetryCounter > 0) {
                addToDebugLog(`Turbo Events have settled but we have not verified frames, using alternate verification...`);
                let tmpContainer = document.querySelector('.todays-lessons-widget__count-text');
                if (tmpContainer && tmpContainer.childElementCount > 0) globalState.todaysLessonsFrameLoaded = true;
                mainSource = 'main() function, no frames alternate verification';
                globalState.mainRetryCounter--;
                addToDebugLog(`Alternate verification process completed, retrying main(), ${globalState.mainRetryCounter} retries left...`);
                await main();
                mainSource = ''
                return;
            }
            else if (globalState.mainRetryCounter === 0) {
                addToDebugLog(`Unable to verify loading of frames through alternate method, please refresh the page to try again from scratch`);
            }
            else {
                addToDebugLog(`Turbo event callbacks are still executing, continuing...`);
            }
            addToDebugLog(`Main function is returning (source = [${mainSource}])`);
            //if (settings.enableDebugging) printDebugLog(INTERNAL_DEBUG_TURBO_HANDLING);
            return;
        }

        let todaysCountForDisplay = todaysLessonsCount;

        if (lessonCountContainer == null) {
            addToDebugLog(`Container is null`);
            addToDebugLog(`Main function is returning (source = [${mainSource}])`);
            //if (settings.enableDebugging) printDebugLog(INTERNAL_DEBUG_TURBO_HANDLING);
            return;
        }
        addToDebugLog(`Container exists`);
        globalState.stateStarting = false;

        if (isNaN(todaysLessonsCount)) todaysCountForDisplay = 0;

        if (lessonCountContainer) {
            if (settings.showTotalOnly) {
                lessonCountContainer.textContent = totalLessonCount;
                addToDebugLog(`Setting display amount for Today's Lessons tile, set to ${lessonCountContainer.textContent}`);
            }
            else {
                lessonCountContainer.textContent = todaysCountForDisplay; // in case it is zero
                let titleContainer = lessonCountContainer.parentNode.parentNode.parentNode; // .todays-lessons-widget__title-container

                if (!titleContainer.children[0].className.includes(`__title-group-container`)) {
                    // clone existing children, and modify clones to make new children, store in a temp array
                    let tempArray = [];
                    for (let elem of titleContainer.children) {
                        let clone = elem.cloneNode(true);
                        if (clone.className.includes(`__subtitle`)) {
                            clone.textContent = "Total";
                        }
                        if (clone.className.includes(`__title`)) {
                            let tmpNode = clone.querySelector(`.todays-lessons-widget__count-text .count-bubble`);
                            if (tmpNode) tmpNode.textContent = totalLessonCount;
                        }
                        tempArray.push(clone);
                    }

                    // wrap the existing children in a new div.todays-lessons-widget__title-group-container
                    // wrap the new children in the same kind of wrapper
                    // append second wrapper after first wrapper

                    let wrapper1 = document.createElement('div');
                    wrapper1.classList.add(`todays-lessons-widget__title-group-container`);
                    titleContainer.insertAdjacentElement('beforebegin', wrapper1);
                    wrapper1.append(...titleContainer.children);
                    titleContainer.prepend(wrapper1); // this should move the wrapper inside titleContainer

                    let wrapper2 = document.createElement('div');
                    wrapper2.classList.add(`todays-lessons-widget__title-group-container`);
                    titleContainer.insertAdjacentElement('beforebegin', wrapper2);
                    wrapper2.append(...tempArray);
                    titleContainer.append(wrapper2);

                    addToDebugLog(`Created additional cloned nodes to display Total Lesson Count.`);
                }
            }
        }

        // hide "Today's" subtitle if showing only total

        let lessonSubtitle = document.querySelector('.todays-lessons-widget__subtitle');

        if (settings.showTotalOnly && lessonSubtitle && lessonSubtitle.checkVisibility()) {
            addToDebugLog(`Hiding the "Today's" subtitle on the lesson tile`);
            lessonSubtitle.style.display = 'none';
        }

        addToDebugLog(`Main function has successfully executed`);

        if (settings.enableDebugging) printDebugLog(INTERNAL_DEBUG_TURBO_HANDLING);
    }
})();