Bunpro - Toolkit

Bunpro study toolkit — audio replay/download, conjugation lookup, and Gemini AI explanation for grammar review sessions.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Bunpro - Toolkit
// @namespace    https://greasyfork.org/
// @version      2.1.2
// @description  Bunpro study toolkit — audio replay/download, conjugation lookup, and Gemini AI explanation for grammar review sessions.
// @author       VictorKndy
// @match        https://bunpro.jp/*
// @grant        none
// @run-at       document-start
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    let lastKnownUrl = "";
    const BTN_SIZE = 36;       // px – compact toolbar buttons
    const BTN_GAP  = 5;        // px – gap between buttons
    let toolbarExpanded = true; // toolbar visible by default

    // --- Gemini AI Configuration ---
    const GEMINI_API_KEY = "";
    const GEMINI_MODEL   = "gemini-3-flash-preview";
    const GEMINI_URI     = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`;

    // --- Gemini API call ---
    async function callGemini(prompt) {
        const response = await fetch(GEMINI_URI, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({
                contents: [{ role: "user", parts: [{ text: prompt }] }],
                generationConfig: {
                    temperature: 0.9,
                    thinkingConfig: { thinkingLevel: "MEDIUM", includeThoughts: false }
                }
            })
        });

        if (!response.ok) {
            throw new Error(`Gemini API error: ${response.status}`);
        }

        const data = await response.json();
        const parts = data.candidates?.[0]?.content?.parts ?? [];
        return parts
            .filter(p => p.text)
            .map(p => p.text)
            .join('')
            .replaceAll('*', '');
    }

    // --- AI explanation display helpers ---
    function resetExplanation() {
        let el = document.getElementById("gm-ai-explanation");
        if (!el) {
            el = document.createElement('div');
            el.id = "gm-ai-explanation";
            el.style.cssText = "margin-top: 10px; font-size: 14px; line-height: 1.5; color: #ddd;";
            const quizEl = document.getElementById('js-tour-quiz-question');
            if (quizEl) quizEl.appendChild(el);
        }
        el.innerText = "";
        el.style.fontStyle = "italic";
        // Remove any existing clear button
        const oldClear = document.getElementById("gm-ai-clear-btn");
        if (oldClear) oldClear.remove();
    }

    function addClearButton() {
        const el = document.getElementById("gm-ai-explanation");
        if (!el) return;
        const clearBtn = document.createElement('button');
        clearBtn.id = "gm-ai-clear-btn";
        clearBtn.innerHTML = '✕';
        clearBtn.title = 'Clear explanation';
        clearBtn.style.cssText = `
            margin-left: 8px; padding: 2px 8px; background: #555; color: #fff;
            border: none; border-radius: 4px; cursor: pointer; font-size: 13px;
            vertical-align: middle;
        `;
        clearBtn.onmousedown = (e) => e.preventDefault(); // prevent stealing focus
        clearBtn.onclick = (e) => {
            e.preventDefault();
            el.innerText = "";
            clearBtn.remove();
        };
        el.appendChild(clearBtn);
    }

    // --- SVG Icons (inline, no external deps) ---
    const ICONS = {
        // Hamburger / toggle
        menu:  `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>`,
        close: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
        // Speaker – play audio
        play:  `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>`,
        // Download
        download: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`,
        // Open book – conjugation
        book:  `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>`,
        // Brain – AI
        brain: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a5 5 0 0 1 5 5c0 1.1-.4 2.1-1 2.9l.2.1a4 4 0 0 1 1.8 3.4 4 4 0 0 1-2.3 3.6c.2.5.3 1 .3 1.5a3.5 3.5 0 0 1-4 3.5 3.5 3.5 0 0 1-4-3.5c0-.5.1-1 .3-1.5A4 4 0 0 1 6 13.4a4 4 0 0 1 1.8-3.4l.2-.1A5 5 0 0 1 7 7a5 5 0 0 1 5-5z"/><path d="M12 2v20"/></svg>`,
    };

    // --- Shared button style ---
    const baseBtnStyle = `
        width: ${BTN_SIZE}px; height: ${BTN_SIZE}px;
        display: flex; align-items: center; justify-content: center;
        border: none; border-radius: 8px;
        cursor: pointer; padding: 0;
        font-family: sans-serif; transition: all 0.2s;
        box-shadow: 0 2px 6px rgba(0,0,0,0.35);
        color: #fff;
    `;

    // --- Helper: create icon button ---
    function makeBtn(id, svgHtml, bg, title) {
        const btn = document.createElement('button');
        btn.id = id;
        btn.innerHTML = svgHtml;
        btn.title = title;
        btn.style.cssText = baseBtnStyle + `background: ${bg};`;
        return btn;
    }

    // --- UI Setup ---
    const setupUI = () => {
        if (document.getElementById("gm-toolkit-container")) return;

        // Container – top-left, vertical column, positioned below Bunpro native buttons
        const container = document.createElement('div');
        container.id = 'gm-toolkit-container';
        container.style.cssText = `
            position: fixed; top: 50px; left: 8px; z-index: 999999;
            display: flex; flex-direction: row; gap: ${BTN_GAP}px;
            align-items: center;
        `;
        document.body.appendChild(container);

        // --- 1. Toggle button (always visible) ---
        const toggleBtn = makeBtn('gm-toggle-btn', ICONS.menu, '#555', 'Show / Hide toolkit');
        container.appendChild(toggleBtn);

        // --- Collapsible group ---
        const toolGroup = document.createElement('div');
        toolGroup.id = 'gm-tool-group';
        toolGroup.style.cssText = `
            display: flex; flex-direction: row; gap: ${BTN_GAP}px;
            transition: opacity 0.2s, max-width 0.3s;
            overflow: hidden;
            align-items: center;
        `;
        container.appendChild(toolGroup);

        // --- 2. Play audio button ---
        const playBtn = makeBtn('gm-play-btn', ICONS.play, '#333', 'Play sentence audio');
        playBtn.style.opacity = '0.4';
        playBtn.style.cursor = 'not-allowed';
        toolGroup.appendChild(playBtn);

        // --- 3. Download button ---
        const dlBtn = makeBtn('gm-download-btn', ICONS.download, '#333', 'Download MP3');
        dlBtn.style.opacity = '0.4';
        dlBtn.style.cursor = 'not-allowed';
        toolGroup.appendChild(dlBtn);

        // --- 4. Conjugation lookup button ---
        const conjBtn = makeBtn('gm-conj-btn', ICONS.book, '#4A148C', 'Conjugation lookup');
        toolGroup.appendChild(conjBtn);

        // --- 5. AI button ---
        const aiBtn = makeBtn('gm-ai-btn', ICONS.brain, '#0D47A1', 'AI assistant');
        toolGroup.appendChild(aiBtn);

        // --- Toggle handler ---
        toggleBtn.onclick = () => {
            toolbarExpanded = !toolbarExpanded;
            toolGroup.style.display = toolbarExpanded ? 'flex' : 'none';
            toggleBtn.innerHTML = toolbarExpanded ? ICONS.close : ICONS.menu;
            toggleBtn.title = toolbarExpanded ? 'Hide toolkit' : 'Show toolkit';
        };

        // --- Play handler ---
        playBtn.onclick = () => {
            if (lastKnownUrl) {
                const a = new Audio(lastKnownUrl);
                a.play().catch(err => {
                    console.error("Playback failed:", err);
                    playBtn.title = 'Error playing audio';
                });
            }
        };

        // --- Download handler ---
        dlBtn.onclick = () => {
            if (lastKnownUrl) {
                const link = document.createElement('a');
                link.href = lastKnownUrl;
                link.setAttribute('download', lastKnownUrl.split('/').pop());
                link.target = '_blank';
                document.body.appendChild(link);
                link.click();
                document.body.removeChild(link);
            }
        };

        // --- Conjugation lookup – open IMABI search in new tab ---
        conjBtn.onclick = () => {
            const sentenceElement = document.querySelector('.bp-quiz-question.relative > div > button');
            const answerOutput = sentenceElement ? sentenceElement.innerText.trim() : "";
            if (answerOutput) {
                navigator.clipboard.writeText(answerOutput).catch(err => console.warn('[Toolkit] Clipboard write failed:', err));
                window.open('https://imabi.org/?s=' + encodeURIComponent(answerOutput), '_blank', 'noopener');
            } else {
                console.log('[Toolkit] No answer output found to look up');
            }
        };

        // --- AI explain handler ---
        aiBtn.onmousedown = (e) => e.preventDefault(); // keep focus behaviour under our control
        aiBtn.onclick = () => {
            // Blur the input so virtual keyboard hides on mobile/tablet
            const inputEl = document.getElementById('js-manual-input');
            if (inputEl) inputEl.blur();
            document.activeElement?.blur();
            if (!document.querySelector('.bp-quiz-console--incorrect')) {
                console.log('[Toolkit] No incorrect answer detected – nothing to explain');
                return;
            }

            resetExplanation();
            const explEl = document.getElementById("gm-ai-explanation");
            if (explEl) explEl.innerText = "Loading explanation...";

            try {
                const sentenceElement = document.querySelector('.bp-quiz-question.relative > div > button');
                const answerSentence = sentenceElement ? sentenceElement.parentNode.innerText.trim() : "";
                const answerOutput = sentenceElement ? sentenceElement.innerText.trim() : "";
                const inputElement = document.getElementById('js-manual-input');
                const userInput = inputElement ? inputElement.value.trim() : "";

                console.log({ answerSentence, answerOutput, userInput });

                const prompt = `Explain the specific grammatical or contextual reason ${answerOutput} fits ${answerSentence} while ${userInput} does not. Provide mechanical reason rather than general definitions. Provide only as much detail as necessary; do not exceed 60 words. Write in English but use Japanese script where necessary; strictly no romaji.`;

                callGemini(prompt)
                    .then(result => {
                        const el = document.getElementById("gm-ai-explanation");
                        if (el) {
                            el.style.fontStyle = "";
                            el.innerText = result;
                            addClearButton();
                        }
                    })
                    .catch(error => {
                        console.error("[Toolkit] AI error:", error);
                        const el = document.getElementById("gm-ai-explanation");
                        if (el) el.innerText = "Error: " + error.message;
                    });
            } catch (e) {
                console.error("[Toolkit] Data extraction failed:", e);
                if (explEl) explEl.innerText = "Could not read question context.";
            }
        };
    };

    // --- Interception Logic ---
    const originalSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'src');

    Object.defineProperty(HTMLAudioElement.prototype, 'src', {
        set: function(url) {
            if (url && typeof url === 'string' && url.includes('.mp3')) {
                handleNewAudio(url);
            }
            return originalSrcDescriptor.set.apply(this, arguments);
        },
        get: function() {
            return originalSrcDescriptor.get.apply(this);
        }
    });

    const originalAttributeSetter = Element.prototype.setAttribute;
    Element.prototype.setAttribute = function(name, value) {
        if (name === 'src' && typeof value === 'string' && value.includes('.mp3')) {
            handleNewAudio(value);
        }
        originalAttributeSetter.apply(this, arguments);
    };

    function handleNewAudio(url) {
        if (url === lastKnownUrl) return;

        console.log("[Toolkit] New Audio Detected:", url);
        lastKnownUrl = url;

        const pBtn = document.getElementById("gm-play-btn");
        const dBtn = document.getElementById("gm-download-btn");

        if (pBtn) {
            pBtn.style.background = "#1B5E20";
            pBtn.style.cursor = "pointer";
            pBtn.style.opacity = "1";
            pBtn.title = "Play sentence audio";
        }

        if (dBtn) {
            dBtn.style.background = "#0D47A1";
            dBtn.style.cursor = "pointer";
            dBtn.style.opacity = "1";
            dBtn.title = "Download MP3";
        }
    }

    const checkInterval = setInterval(() => {
        if (document.body) {
            setupUI();
            clearInterval(checkInterval);
        }
    }, 100);

})();