MaruMori Anki Mode

In ANKI mode, review cards on marumori.io without typing: press Enter to reveal the answer, then press "o" for correct or "p" for wrong.

// ==UserScript==
// @name         MaruMori Anki Mode
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  In ANKI mode, review cards on marumori.io without typing: press Enter to reveal the answer, then press "o" for correct or "p" for wrong.
// @author       Matskye
// @icon         https://www.google.com/s2/favicons?sz=64&domain=marumori.io
// @match        https://marumori.io/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // ── Inject CSS to Hide the Input Field ──
    var style = document.createElement('style');
    style.innerHTML = 'input.pan_input { position: absolute !important; left: -9999px !important; }';
    document.head.appendChild(style);

    // ── Configuration & Mode Detection ──
    // Always enable ANKI mode in this version.
    let ankiMode = true;
    console.log("[ANKI Mode] ANKI mode enabled (always).");

    // Global flag to suppress handling of synthetic events.
    let simulating = false;

    // ── Utility Functions ──
    function normalizeMeaning(text) {
        if (!text) return "";
        let s = text.toLowerCase().trim();
        s = s.replace(/-/g, " ");
        s = s.replace(/['’]/g, ""); // Remove both straight and typographic apostrophes
        s = s.replace(/\s+/g, " ");
        s = s.replace(/^[.,;:!?]+|[.,;:!?]+$/g, "");
        return s;
    }
    function normalizeAnswerVariants(text) {
        text = text.trim();
        let v1 = normalizeMeaning(text);
        let v2 = normalizeMeaning(text.replace(/\s*\([^)]*\)/g, ""));
        return Array.from(new Set([v1, v2]));
    }
    // Simulate an Enter key press (dispatch both keydown and keyup events)
    function simulateEnter(el) {
        simulating = true;
        let evtDown = new KeyboardEvent("keydown", {
            key: "Enter",
            code: "Enter",
            keyCode: 13,
            which: 13,
            bubbles: true
        });
        let evtUp = new KeyboardEvent("keyup", {
            key: "Enter",
            code: "Enter",
            keyCode: 13,
            which: 13,
            bubbles: true
        });
        el.dispatchEvent(evtDown);
        el.dispatchEvent(evtUp);
        setTimeout(() => { simulating = false; }, 50);
    }
    // Instead of a second Enter, click the next-arrow button.
    function clickNextArrow() {
        let nextArrow = document.querySelector("button.next-arrow");
        if (nextArrow) {
            nextArrow.click();
            console.log("[ANKI Mode] Next arrow clicked.");
        } else {
            console.log("[ANKI Mode] Next arrow button not found.");
        }
    }

    // ── Accepted Answer Extraction ──
    function getAcceptedAnswer(fieldType) {
        let wrappers = document.querySelectorAll('.full_wrap .left_small .item_wrapper');
        if (fieldType === 'reading') {
            // Try for "reading" header first.
            for (let wrapper of wrappers) {
                let header = wrapper.querySelector('h4');
                if (!header) continue;
                if (header.textContent.trim().toLowerCase() === 'reading') {
                    let span = wrapper.querySelector('span.primary') || wrapper.querySelector('span.reading');
                    if (span && span.textContent.trim()) {
                        console.log('[ANKI Mode] Found accepted reading (vocabulary):', span.textContent.trim());
                        return span.textContent.trim();
                    }
                }
            }
            // Then try "kunyomi".
            for (let wrapper of wrappers) {
                let header = wrapper.querySelector('h4');
                if (!header) continue;
                if (header.textContent.trim().toLowerCase() === 'kunyomi') {
                    let span = wrapper.querySelector('span.reading');
                    if (span && span.textContent.trim()) {
                        console.log('[ANKI Mode] Found accepted reading (kunyomi):', span.textContent.trim());
                        return span.textContent.trim();
                    }
                }
            }
            // Finally, try "onyomi".
            for (let wrapper of wrappers) {
                let header = wrapper.querySelector('h4');
                if (!header) continue;
                if (header.textContent.trim().toLowerCase() === 'onyomi') {
                    let span = wrapper.querySelector('span.reading');
                    if (span && span.textContent.trim()) {
                        console.log('[ANKI Mode] Found accepted reading (onyomi):', span.textContent.trim());
                        return span.textContent.trim();
                    }
                }
            }
        } else if (fieldType === 'meaning') {
            // For meaning, check for wrappers with header "meaning" or "meanings"
            for (let wrapper of wrappers) {
                let header = wrapper.querySelector('h4');
                if (!header) continue;
                let ht = header.textContent.trim().toLowerCase();
                if (ht === 'meaning' || ht === 'meanings') {
                    let spans = wrapper.querySelectorAll('span.meaning');
                    if (spans && spans.length > 0) {
                        // Instead of joining all spans, return the first one.
                        let candidate = spans[0].textContent.trim();
                        console.log('[ANKI Mode] Found accepted meaning (first candidate):', candidate);
                        return candidate;
                    }
                }
            }
            // Fallback: try to find a paragraph with class "spoiler" in .left_small
            let p = document.querySelector('.left_small p.spoiler');
            if (p) {
                let spans = p.querySelectorAll('span.meaning');
                if (spans && spans.length > 0) {
                    let candidate = spans[0].textContent.trim();
                    console.log('[ANKI Mode] Found accepted meaning (fallback, first candidate):', candidate);
                    return candidate;
                }
            }
        }
        console.log('[ANKI Mode] Accepted answer not found for field:', fieldType);
        return "";
    }
    function isExactMatch(submitted, accepted, fieldType) {
        if (!submitted || !accepted) return false;
        if (fieldType === 'reading') {
            let candidates = accepted.split(";").map(s => s.trim()).filter(Boolean);
            console.log('[ANKI Mode] Reading candidates:', candidates);
            return candidates.includes(submitted);
        } else if (fieldType === 'meaning') {
            let candidates = (accepted.includes(";") || accepted.includes("\n"))
                ? accepted.split(/;|\n/).map(s => s.trim()).filter(Boolean)
                : [accepted];
            console.log('[ANKI Mode] Raw meaning candidates:', candidates);
            let normSubmitted = normalizeMeaning(submitted);
            for (let candidate of candidates) {
                let variants = normalizeAnswerVariants(candidate);
                console.log('[ANKI Mode] Candidate variants for "' + candidate + '":', variants);
                if (variants.includes(normSubmitted)) return true;
            }
            return false;
        }
        return false;
    }

    // ── Global Variables for ANKI Mode ──
    let waitingForRating = false; // true when answer is revealed and waiting for rating ("o" or "p")
    let lastFieldType = "";       // "reading" or "meaning"
    let acceptedAnswer = "";      // the correct answer as extracted from the card

    // ── ANKI Mode Key Listener ──
    function ankiKeyListener(e) {
        if (simulating) return;
        let input = document.querySelector('input.pan_input');
        if (!input) return;
        // If not waiting for rating, pressing Enter reveals the answer.
        if (!waitingForRating && e.key === "Enter") {
            if (!input.disabled) {
                input.focus();
                input.value = "nononono";  // dummy wrong answer
                simulateEnter(input);      // submit dummy answer to reveal card
                waitingForRating = true;   // now wait for rating
                e.preventDefault();
            }
        } else if (waitingForRating) {
            // While waiting for rating, use "o" for correct and "p" for wrong.
            if (e.key === "o") {
                acceptedAnswer = getAcceptedAnswer(lastFieldType);
                // Mark as correct:
                setTimeout(() => {
                    input.value = ""; // clear dummy answer
                    // Simulate backspace events to mimic manual clearing.
                    let backspaceDown = new KeyboardEvent("keydown", {
                        key: "Backspace",
                        code: "Backspace",
                        keyCode: 8,
                        which: 8,
                        bubbles: true
                    });
                    let backspaceUp = new KeyboardEvent("keyup", {
                        key: "Backspace",
                        code: "Backspace",
                        keyCode: 8,
                        which: 8,
                        bubbles: true
                    });
                    input.dispatchEvent(backspaceDown);
                    input.dispatchEvent(backspaceUp);
                    input.dispatchEvent(new Event("input", { bubbles: true }));
                    setTimeout(() => {
                        input.value = acceptedAnswer; // enter correct answer
                        setTimeout(() => {
                            simulateEnter(input); // first Enter to check
                            setTimeout(() => {
                                // Instead of a second Enter, click the next arrow.
                                clickNextArrow();
                                console.log("[ANKI Mode] Marked as CORRECT. Submitted the correct answer.");
                            }, 200);
                        }, 20);
                    }, 20);
                }, 20);
            } else if (e.key === "p") {
                // Mark as wrong: after a 200ms delay, click the next arrow.
                setTimeout(() => {
                    clickNextArrow();
                    console.log("[ANKI Mode] Marked as WRONG.");
                }, 200);
            }
            waitingForRating = false;
            e.preventDefault();
        }
    }

    // ── Mutation Observer for ANKI Mode ──
    function ankiMutationCallback(mutations) {
        mutations.forEach(mutation => {
            if (mutation.type === "attributes" && mutation.attributeName === "disabled") {
                let input = mutation.target;
                if (input && input.matches("input.pan_input") && input.disabled) {
                    let placeholder = input.getAttribute("placeholder") || "";
                    if (placeholder.includes("読み方") || placeholder.toLowerCase().includes("reading")) {
                        lastFieldType = "reading";
                    } else if (placeholder.toLowerCase().includes("meaning")) {
                        lastFieldType = "meaning";
                    } else {
                        lastFieldType = "";
                    }
                    console.log("[ANKI Mode] Card processed. Field type:", lastFieldType);
                }
            }
        });
    }

    // ── Initialization for ANKI Mode ──
    function initAnkiMode() {
        console.log("[ANKI Mode] Initializing ANKI mode...");
        document.addEventListener("keydown", ankiKeyListener, true);
        let observer = new MutationObserver(ankiMutationCallback);
        observer.observe(document.body, { attributes: true, subtree: true });
    }

    // Always enable ANKI mode.
    initAnkiMode();
    console.log("[ANKI Mode] ANKI mode is active.");

})();