Bunpro Toolbox

Adds search links for Japanese resources

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.

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

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 Toolbox
// @version      1.7.0
// @description  Adds search links for Japanese resources
// @author       Ambo100
// @match        *://bunpro.jp/grammar_points/*
// @match        *://bunpro.jp/learn*
// @match        *://bunpro.jp/vocabs/*
// @grant        none
// @namespace    https://greasyfork.org/users/230700
// ==/UserScript==

(function () {
    'use strict';

    const STYLE_ID = 'bp-toolbox-style';
    const POLL_MS = 1000;

    const GRAMMAR_TEMPLATE = `
Core Sections:
- Meaning
- Structure
- Usage
- Example Sentences
- Common Patterns
- Facts

Optional:
- Facts
- Contrast
- Etymology
- Common Mistakes
`;

    const VOCAB_TEMPLATE = `
Core Sections:
- Meaning
- Nuance
- Usage
- Example Sentences

Optional:
- Facts
- Kanji Breakdown
- Common Collocations
- Related Vocab
`;

    const AI_TEMPLATE = `
Role: Japanese Tutor
Task: Explain this Japanese {itemType} clearly, practically, and in a structured textbook style.
Input: {currentItem}
Rules:
- Follow the section order exactly
- Use clear headings, tables and pretty formatting. Textbook style.
- Give 3 natural Japanese example sentences with English translations
- No romaji
- Include upto 3-4 concise facts
- Include common mistakes if relevant

{template}
`;

    const CLASSES = {
        launcher: 'bp-toolbox-launcher',
        panel: 'bp-toolbox-panel',
        open: 'bp-toolbox-open',
        header: 'bp-toolbox-header',
        title: 'bp-toolbox-title',
        body: 'bp-toolbox-body',
        section: 'bp-toolbox-section',
        sectionTitle: 'bp-toolbox-section-title',
        links: 'bp-toolbox-links',
        button: 'bp-toolbox-button',
        close: 'bp-toolbox-close'
    };

    const RESOURCES = [
        {
            title: 'Dictionaries',
            items: [
                { label: 'Jisho', url: 'https://jisho.org/search/{currentItem}' },
                { label: 'Wiktionary', url: 'https://en.wiktionary.org/wiki/{currentItem}#Japanese' },
                { label: 'Eijirou', url: 'https://eow.alc.co.jp/search?q={currentItem}' },
                { label: 'Weblio', url: 'https://www.weblio.jp/content/{currentItem}' }
            ]
        },
        {
            title: 'Video',
            items: [
                { label: 'YouGlish', url: 'https://youglish.com/pronounce/{currentItem}/japanese?' },
                { label: 'YouTube', url: 'https://www.youtube.com/results?search_query={currentItem}+Japanese' }
            ]
        },
        {
            title: 'Community',
            items: [
                { label: 'Stack Exchange', url: 'https://japanese.stackexchange.com/search?q={currentItem}' },
                { label: 'Reddit', url: 'https://www.reddit.com/r/LearnJapanese/search?q={currentItem}&restrict_sr=on&sort=relevance&t=all' },
                { label: 'WK Forum', url: 'https://community.wanikani.com/search?q={currentItem}%20category%3A17' }
            ]
        },
        {
            title: 'AI',
            items: [
                { label: 'ChatGPT', url: 'https://chat.openai.com/?q={prompt}', ai: true },
                { label: 'Claude', url: 'https://claude.ai/new?q={prompt}', ai: true }
            ]
        }
    ];

    const STYLES = `
        .${CLASSES.launcher} {
            position: fixed;
            right: 20px;
            bottom: 20px;
            z-index: 2147483647;
            min-height: 40px;
            padding: 0 14px;
            border: 1px solid rgba(255,255,255,0.16);
            border-radius: 999px;
            background: #1f2933;
            color: #e5e7eb;
            cursor: pointer;
            font: 600 14px system-ui, sans-serif;
        }

        .${CLASSES.panel} {
            position: fixed;
            right: 20px;
            bottom: 68px;
            z-index: 2147483647;
            width: 300px;
            max-height: 70vh;
            display: flex;
            flex-direction: column;
            border: 1px solid rgba(255,255,255,0.12);
            border-radius: 12px;
            background: #1f2933;
            color: #e5e7eb;
            box-shadow: 0 12px 30px rgba(0,0,0,0.3);
            overflow: hidden;
            opacity: 0;
            pointer-events: none;
            transform: translateY(8px);
            transition: opacity 120ms ease, transform 120ms ease;
        }

        .${CLASSES.panel}.${CLASSES.open} {
            opacity: 1;
            pointer-events: auto;
            transform: translateY(0);
        }

        .${CLASSES.header} {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 8px;
            padding: 12px;
            border-bottom: 1px solid rgba(255,255,255,0.08);
        }

        .${CLASSES.title} {
            min-width: 0;
            overflow: hidden;
            text-overflow: ellipsis;
            white-space: nowrap;
            font: 600 14px system-ui, sans-serif;
        }

        .${CLASSES.close} {
            width: 28px;
            height: 28px;
            padding: 0;
        }

        .${CLASSES.body} {
            overflow-y: auto;
            padding: 12px;
            display: flex;
            flex-direction: column;
            gap: 12px;
        }

        .${CLASSES.section} {
            display: flex;
            flex-direction: column;
            gap: 8px;
        }

        .${CLASSES.sectionTitle} {
            font: 600 11px system-ui, sans-serif;
            letter-spacing: 0.04em;
            text-transform: uppercase;
            opacity: 0.7;
        }

        .${CLASSES.links} {
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
        }

        .${CLASSES.button} {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            min-height: 32px;
            padding: 0.45rem 0.7rem;
            border: 1px solid rgba(255,255,255,0.14);
            border-radius: 999px;
            background: transparent;
            color: inherit;
            text-decoration: none;
            cursor: pointer;
            font: 500 13px system-ui, sans-serif;
            line-height: 1.2;
            transition: transform 120ms ease, background-color 120ms ease, border-color 120ms ease;
        }

        .${CLASSES.button}:hover,
        .${CLASSES.button}:focus-visible {
            background: rgba(255,255,255,0.08);
            border-color: rgba(255,255,255,0.3);
            transform: translateY(-1px);
            outline: none;
        }
    `;

    let currentUrl = window.location.href;
    let currentItem = '';
    let launcher = null;
    let panel = null;
    let titleEl = null;
    let bodyEl = null;

    window.addEventListener('load', initialize, false);

    function resolveCurrentItem() {
        const itemNode = document.querySelector('h1 .bp-ddw, .bp-ddw');
        if (!itemNode) return '';

        const clonedNode = itemNode.cloneNode(true);
        clonedNode.querySelectorAll('rt, rp').forEach((rubyElement) => rubyElement.remove());

        return clonedNode.textContent?.trim() || '';
    }

    function getPageType() {
        const path = window.location.pathname;
        if (path.startsWith('/vocabs/')) return 'vocab';
        if (path.startsWith('/grammar_points/')) return 'grammar';
        return '';
    }

    function getItemType() {
        return getPageType() === 'vocab' ? 'vocabulary item' : 'grammar point';
    }

    function getSubTemplate() {
        return getPageType() === 'vocab'
            ? VOCAB_TEMPLATE.trim()
            : GRAMMAR_TEMPLATE.trim();
    }

    function getAiPrompt() {
        return AI_TEMPLATE
            .replace('{itemType}', getItemType())
            .replace('{currentItem}', currentItem)
            .replace('{template}', getSubTemplate())
            .trim();
    }

    function initialize() {
        ensureStyles();

        const nextItem = resolveCurrentItem();
        if (!nextItem) {
            currentItem = '';
            removeUI();
            return;
        }

        ensureUI();

        const needsRender =
            nextItem !== currentItem ||
            !bodyEl ||
            !bodyEl.childElementCount;

        currentItem = nextItem;
        updateTitle();

        if (needsRender) {
            renderSections();
        }
    }

    function ensureStyles() {
        if (document.getElementById(STYLE_ID)) return;

        const style = document.createElement('style');
        style.id = STYLE_ID;
        style.textContent = STYLES;
        document.head.appendChild(style);
    }

    function ensureUI() {
        if (!launcher) {
            launcher = el('button', CLASSES.launcher, 'Toolbox');
            launcher.type = 'button';
            launcher.setAttribute('aria-expanded', 'false');
            launcher.addEventListener('click', togglePanel);
            document.body.appendChild(launcher);
        }

        if (!panel) {
            panel = el('section', CLASSES.panel);

            const header = el('div', CLASSES.header);
            titleEl = el('div', CLASSES.title);

            const close = el('button', `${CLASSES.button} ${CLASSES.close}`, '×');
            close.type = 'button';
            close.addEventListener('click', closePanel);

            bodyEl = el('div', CLASSES.body);

            header.append(titleEl, close);
            panel.append(header, bodyEl);
            document.body.appendChild(panel);
        }

        bindGlobalEvents();
    }

    function bindGlobalEvents() {
        if (document.body.dataset.bpToolboxBound === 'true') return;
        document.body.dataset.bpToolboxBound = 'true';

        document.addEventListener('keydown', (event) => {
            if (event.key === 'Escape') closePanel();
        });

        document.addEventListener('click', (event) => {
            if (!panel?.classList.contains(CLASSES.open)) return;
            if (panel?.contains(event.target) || launcher?.contains(event.target)) return;
            closePanel();
        });
    }

    function renderSections() {
        if (!bodyEl) return;

        bodyEl.innerHTML = '';

        for (const group of RESOURCES) {
            const links = group.items.map(buildItem).filter(Boolean);
            if (!links.length) continue;

            const section = el('div', CLASSES.section);
            const heading = el('div', CLASSES.sectionTitle, group.title);
            const linkWrap = el('div', CLASSES.links);

            linkWrap.append(...links);
            section.append(heading, linkWrap);
            bodyEl.appendChild(section);
        }
    }

    function buildItem(item) {
        const url = buildUrl(item);
        if (!url) return null;

        const link = el('a', CLASSES.button, item.label);
        link.href = url;
        link.target = '_blank';
        link.rel = 'noopener noreferrer';

        return link;
    }

    function buildUrl(item) {
        if (!currentItem || !item.url) return '';

        if (item.ai) {
            const prompt = getAiPrompt();
            return item.url.replace('{prompt}', encodeURIComponent(prompt));
        }

        if (item.prompt) {
            const prompt = item.prompt.replace('{currentItem}', currentItem);
            return item.url.replace('{prompt}', encodeURIComponent(prompt));
        }

        return item.url.replace('{currentItem}', encodeURIComponent(currentItem));
    }

    function updateTitle() {
        if (titleEl) {
            titleEl.textContent = currentItem || 'Bunpro Toolbox';
        }
    }

    function togglePanel() {
        panel?.classList.contains(CLASSES.open) ? closePanel() : openPanel();
    }

    function openPanel() {
        if (!panel) return;
        panel.classList.add(CLASSES.open);
        launcher?.setAttribute('aria-expanded', 'true');
    }

    function closePanel() {
        if (!panel) return;
        panel.classList.remove(CLASSES.open);
        launcher?.setAttribute('aria-expanded', 'false');
    }

    function removeUI() {
        launcher?.remove();
        panel?.remove();
        launcher = null;
        panel = null;
        titleEl = null;
        bodyEl = null;
    }

    function el(tag, className = '', text = '') {
        const node = document.createElement(tag);
        if (className) node.className = className;
        if (text) node.textContent = text;
        return node;
    }

    setInterval(() => {
        if (window.location.href !== currentUrl) {
            currentUrl = window.location.href;
            initialize();
        }
    }, POLL_MS);
})();