Lichess Puzzle Timer

Adds a timer to the lichess.org puzzle trainer

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Lichess Puzzle Timer
// @namespace    http://tampermonkey.net/
// @license      MIT
// @version      1.7
// @description  Adds a timer to the lichess.org puzzle trainer
// @author       https://github.com/cristoper/
// @match        https://lichess.org/training*
// @resource     style https://raw.githubusercontent.com/cristoper/lichess-puzzle-timer/refs/heads/main/lctimer.css
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_getResourceText
// @grant        GM_addStyle
// ==/UserScript==
(function() {
    'use strict';

    // Storage abstraction that works in both browser extensions and Tampermonkey
    class Storage {
        static setItem(key, value) {
            if (typeof chrome !== 'undefined' && chrome.storage) {
                const val = {};
                val[key] = value;
                return chrome.storage.local.set(val);
            } else {
                return GM_setValue(key, value);
            }
        }

        static getItem(key) {
            if (typeof chrome !== 'undefined' && chrome.storage) {
                return chrome.storage.local.get(key);
            } else {
                let result = {};
                const v = GM_getValue(key);
                result[key] = v;
                return Promise.resolve(result);
            }
        }
    }

    class LCSettings {
        constructor() {
            // TODO set/get settings from local storage
            this.enabled = true;
            this.slowMode = true;
            this.autoFail = false;
            this.startTime = 60 * 1000; // ms
            this.settingsCallback = function() {};

            const template =
    `
    <dialog id='lctimer-settings'>

        <div class="lctimer-set">
            <input type="number" id="lctimer-min" min="0" max="59" value="0">
            <label for="lctimer-min">Minutes</label>
            <input type="number" id="lctimer-time" min="0" max="59" value="0">
            <label for="lctimer-time">Seconds</label>
        </div>

        <group id='lctimer-mode' class='radio'>
            <div>
                <input id="lctimer-mode-slow" type="radio" value="slow" name="mode"><label for="lctimer-mode-slow">Thinking mode</label>
            </div>
            <div>
                <input id="lctimer-mode-fast" type="radio" value="fast" name="mode" checked=""><label for="lctimer-mode-fast">Blitz mode</label>
            </div>
        </group>

        <div class="lcsettings-row">
            <label for="autofail-btn">Autofail (lose points if too slow):</label>
            <div class="cmn-toggle">
                <input id="autofail-btn" class="form-control cmn-toggle" name="autofail" value="false" type="checkbox" checked="unchecked">
                <label for="autofail-btn"></label>
            </div>
        </div>

        <form method="dialog"><button id="doneBtn" class="button">Done</button></form>
    </dialog>
    `
            document.body.insertAdjacentHTML('beforeend', template);
            this.settingsDialog = document.getElementById('lctimer-settings');
            this.minInput = document.getElementById('lctimer-min');
            this.timeInput = document.getElementById('lctimer-time');
            this.modeSlow = document.getElementById('lctimer-mode-slow');
            this.modeFast = document.getElementById('lctimer-mode-fast');
            this.autoFailBtn = document.getElementById('autofail-btn');
            this.doneBtn = document.getElementById('doneBtn');

            // load settings
            this.loadSettings();
            
            const modeGroup = document.getElementById('lctimer-mode');
            modeGroup.addEventListener('change', () => {
                if (this.modeSlow.checked) {
                    document.querySelector('.lcsettings-row').style.visibility = 'hidden';
                } else {
                    document.querySelector('.lcsettings-row').style.visibility = 'visible';
                }
            });

            // when dialog closes, update settings and emit event
            this.settingsDialog.addEventListener('close', (e) => {
                let didUpdate = false;
                if (this.minInput.value == "") {
                    this.minInput.value = 0;
                }
                if (this.timeInput.value == "") {
                    if (this.minInput.value == 0) {
                        this.timeInput.value = Math.floor(this.startTime / 1000 % 60);
                    } else {
                        this.timeInput.value = 0;
                    }
                }

                let newTime = (parseInt(this.minInput.value) * 60 + parseInt(this.timeInput.value)) * 1000;
                const newMode = this.modeSlow.checked;
                const newAutoFail = this.autoFailBtn.checked;

                if (newTime < 1000) {
                    newTime = this.startTime;
                }

                if (this.startTime != newTime) {
                    this.startTime = newTime;
                    didUpdate = true;
                }
                if (this.slowMode != newMode) {
                    this.slowMode = newMode;
                    didUpdate = true;
                }
                if (this.autoFail != newAutoFail) {
                    this.autoFail = newAutoFail;
                    didUpdate = true;
                }

                this.saveSettings();
                this.updateDOM(); // to nromalize minute and second text inputs


                if (didUpdate) {
                    this.settingsChanged();
                }
            });

        }

        settingsChanged() {
            this.settingsCallback();
            this.updateDOM();
        }

        updateDOM() {
            this.minInput.value = Math.floor(this.startTime / 1000 / 60);
            this.timeInput.value = Math.floor(this.startTime / 1000 % 60);
            this.modeSlow.checked = this.slowMode;
            this.modeFast.checked = !this.slowMode;
            this.autoFailBtn.checked = this.autoFail;
            // only show autofail when Blitz mode selected
            if (this.modeSlow.checked) {
                document.querySelector('.lcsettings-row').style.visibility = 'hidden';
            } else {
                document.querySelector('.lcsettings-row').style.visibility = 'visible';
            }
        }

        // load settings from chrome.local
        async loadSettings() {
            const settings = Storage.getItem("settings").then((items) => {
                if (typeof items!== 'undefined' && items.settings) {
                    this.enabled = items.settings.enabled;
                    this.slowMode = items.settings.slowMode;
                    this.autoFail = items.settings.autoFail;
                    this.startTime = items.settings.startTime;
                }
                this.settingsChanged();
            });
        }

        saveSettings() {
            Storage.setItem("settings", {
                enabled: this.enabled,
                slowMode: this.slowMode,
                autoFail: this.autoFail,
                startTime: this.startTime
            });
        }

        showDialog() {
            this.settingsDialog.showModal();
        }
    }

    class LCPuzzleTimer {
        constructor(container, settings) {
            this.container = container;
            this.settings = settings;
            this.running = false;
            this.time = settings.startTime;

            this.tickFreq = 100; //ms
            this.lastTick = null;
            this.lastColonFlash = Date.now();
            this.startTime = null;

            this.flashBG = false;

            const template = `
    <div class="cmn-toggle" role="button" title="Toggle Lichess Timer Extension">
    <input id="lctimer-toggle-enabled" class="cmn-toggle cmn-toggle--subtle" type="checkbox">
    <label for="lctimer-toggle-enabled"></label>
    </div>

    <div id='lcpuzzletimer'><span id='lcminutes'>00</span><span id='lccolon'>:</span><span id='lcseconds'>00</span></div>

    <button class="settings-gear" role="button" data-icon="" id="lctimer-settings-btn" title="Lichess Puzzle Timer settings"></button>
    `
            container.insertAdjacentHTML('beforeend', template)

            this.board = document.querySelector("cg-board");
            this.lcminutes = document.getElementById('lcminutes')
            this.lcseconds = document.getElementById('lcseconds')
            this.lccolon = document.getElementById('lccolon')

            this.settingsButton = document.getElementById('lctimer-settings-btn');

            // so that we only add a single event listener to cg-board
            this.boundClickedBoard = this.clickedBoard.bind(this);

            // event listeners
            this.settings.settingsCallback = () => {
                this.reset();
                if (this.settings.enabled) {
                    this.start();
                }
            };

            this.settingsButton.addEventListener('click', this.clickedSettings.bind(this));

            this.enableButton = document.getElementById('lctimer-toggle-enabled');
            this.enableButton.addEventListener('change', (e) => {
                if (e.target.checked) {
                    this.settings.enabled = true;
                    this.reset();
                    this.start();
                } else {
                    this.settings.enabled = false;
                    this.reset();
                }
                this.settings.saveSettings();
            });

            // We detect when a new puzzle starts by detecting when
            // the div.puzzle_feedback.after element is removed from puzzle__tools
            const board = document.querySelector(".main-board");
            const puzzletools = document.querySelector(".puzzle__tools");
            const cgCallback = (mutationsList, observer) => {
                for (let mutation of mutationsList) {
                    if (mutation.type === 'childList') {
                        // if div.puzzle__feedback.after is removed, assume new puzzle
                        for (let node of mutation.removedNodes) {
                            if (node.classList && node.classList.contains('puzzle__feedback')
                                && node.classList.contains('after'))
                            {
                                this.newPuzzle();
                                return;
                            }
                        }
                        // check for success and fail feedback messages
                        for (let node of mutation.addedNodes) {
                            if (node.classList && node.classList.contains('puzzle__feedback')
                                && node.classList.contains('fail'))
                            {
                                this.puzzleFailed();
                                return;
                            }
                            if (node.classList && node.classList.contains('puzzle__feedback') && node.classList.contains('after'))
                            {
                                this.puzzleSucceeded();
                                return;
                            }
                        }
                    }
                }
            };
            const cgobserver = new MutationObserver(cgCallback);
            cgobserver.observe(puzzletools, {childList: true, subtree: true});

            this.reset();
        }

        clickedSettings() {
            this.settings.showDialog();
        }

        newPuzzle() {
            this.reset();
            this.start();
        }

        puzzleFailed() {
            // in Blitz mode when the user fails puzzle
            // do nothing: all player to keep trying with timer running
            // this.expired();
        }

        puzzleSucceeded() {
            // in Blitz mode when the user succeeds puzzle
            this.stop();
        }

        reset() {
            this.stop();
            this.flashBG = false;
            this.time = this.settings.startTime;

            // the reference to cg-board can break between puzzles
            this.board = document.querySelector("cg-board");
            this.board.addEventListener("mousedown", this.boundClickedBoard, true);

            this.render();
        }

        start() {
            if (!this.settings.enabled) {
                return;
            }
            this.running = true;
            this.startTime = Date.now();
            this.lastTick = this.startTime;
            this.timer = setInterval(() => {
                this.tick();
            }, this.tickFreq);
        }

        stop() {
            if (this.timer) {
                this.running = false;
                clearInterval(this.timer);
                this.timer = null;
            }
        }

        expired() {
            this.stop();
            this.time = 0;

            if (!this.settings.slowMode && this.settings.autoFail) {
                clickViewSolution();
            }
            this.render();
        }


        tick() {
            if (this.running) {
                const now = Date.now();
                const diff = now - this.lastTick;
                this.time -= diff;
                this.lastTick = now;
                if (this.time <= 0) {
                    this.expired();
                }
            }
            this.render();
        }

        renderBoardBackground() {
            if (!this.settings.enabled) {
                this.board.style.boxShadow = "";
                return;
            }
            if (this.settings.slowMode) {
                if (this.running) {
                    if (this.flashBG) {
                        this.board.style.boxShadow = "0px 0px 8px 2px red";
                    } else {
                        this.board.style.boxShadow = "0px 0px 6px 1px red";
                    }
                } else {
                    this.board.style.boxShadow = "0px 0px 6px 1px green";
                }
            } else {
                // fast mode
                if (this.running) {
                    this.board.style.boxShadow = "0px 0px 6px 1px green";
                } else {
                    this.board.style.boxShadow = "0px 0px 6px 1px red";
                }
            }
        }

        renderSettings() {
            this.enableButton.checked = this.settings.enabled;
        }

        render() {
            const minutes = Math.floor((this.time / 1000) / 60);
            const seconds = (this.time / 1000) % 60;

            let fixed = 1;
            if (this.time > 10 * 1000) {
                fixed = 0;
            }

            this.renderSettings();
            this.renderBoardBackground();

            this.lcminutes.textContent = minutes.toString().padStart(2, '0');
            this.lcseconds.textContent = seconds.toFixed(fixed).padStart(2, '0');

            if (Date.now() - this.lastColonFlash > 500) {
                if (this.lccolon.style.opacity == 1 ) {
                    this.lccolon.style.opacity = 0.5;
                } else {
                    this.lccolon.style.opacity = 1.0;
                }
                this.lastColonFlash = Date.now();
            }
        }

        clickedBoard(event) {
            // user tried to click board while locked, flash the background
            // and block event
            const leftButton = event.button === 0;
            if (this.running && this.settings.enabled && this.settings.slowMode && leftButton) {
                this.flashBG = true;
                setTimeout(() => {
                    this.flashBG = false;
                }, 100);
                event.stopPropagation(); // prevent making moves on the board
            }
        }

    }

    function clickViewSolution() {
        document.querySelector(".view_solution").querySelectorAll("a")[1].click();
    }

    function startExt() {
        // tampermonkey: load css
        if (typeof GM_addStyle !== 'undefined') {
            const cssstyle = GM_getResourceText("style");
            GM_addStyle(cssstyle);
        }

        // Create container at top of tools
        const puzzle_tools = document.querySelector(".puzzle__tools");
        const timer = document.createElement("div")
        timer.className = "lctimer-container";
        puzzle_tools.prepend(timer);

        const settings = new LCSettings();
        const app = new LCPuzzleTimer(timer, settings);

        app.newPuzzle();
    }

    // set up timer on load
    if (document.readyState === 'complete') {
        startExt();
    } else {
        window.addEventListener("load", startExt);
    }
})();