JPDB Immersion Kit Examples

Embeds anime images & audio examples into JPDB review and vocabulary pages using Immersion Kit's API. Compatible only with TamperMonkey.

נכון ליום 08-09-2024. ראה הגרסה האחרונה.

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         JPDB Immersion Kit Examples
// @version      1.4
// @description  Embeds anime images & audio examples into JPDB review and vocabulary pages using Immersion Kit's API. Compatible only with TamperMonkey.
// @author       awoo
// @namespace    jpdb-immersion-kit-examples
// @match        https://jpdb.io/review*
// @match        https://jpdb.io/vocabulary/*
// @match        https://jpdb.io/kanji/*
// @grant        GM_addElement
// @grant        GM_xmlhttpRequest
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const CONFIG = {
        IMAGE_WIDTH: '400px',
        ENABLE_EXAMPLE_TRANSLATION: true,
        SENTENCE_FONT_SIZE: '120%',
        TRANSLATION_FONT_SIZE: '85%',
        COLORED_SENTENCE_TEXT: true,
        AUTO_PLAY_SOUND: true,
        SOUND_VOLUME: 0.8,
        NUM_PRELOADS: 1,
        WIDE_MODE: true,
        PAGE_MAX_WIDTH: '75rem'

    };

    const state = {
        currentExampleIndex: 0,
        examples: [],
        apiDataFetched: false,
        vocab: '',
        embedAboveSubsectionMeanings: false,
        preloadedIndices: new Set(),
        currentAudio: null,
        exactSearch: true
    };

    function getImmersionKitData(vocab, exactSearch, callback) {
        const searchVocab = exactSearch ? `「${vocab}」` : vocab;
        const url = `https://api.immersionkit.com/look_up_dictionary?keyword=${encodeURIComponent(searchVocab)}&sort=shortness`;

        GM_xmlhttpRequest({
            method: "GET",
            url: url,
            onload: function(response) {
                if (response.status === 200) {
                    try {
                        const jsonData = JSON.parse(response.responseText);
                        if (jsonData.data && jsonData.data[0] && jsonData.data[0].examples) {
                            state.examples = jsonData.data[0].examples;
                            state.apiDataFetched = true;
                        }
                    } catch (e) {
                        console.error('Error parsing JSON response:', e);
                    }
                }
                callback();
            },
            onerror: function(error) {
                console.error('Error fetching data:', error);
                callback();
            }
        });
    }

    function getStoredData(key) {
        const storedValue = localStorage.getItem(key);
        if (storedValue) {
            const [index, exactState] = storedValue.split(',');
            return {
                index: parseInt(index, 10),
                exactState: exactState === '1'
            };
        }
        return { index: 0, exactState: state.exactSearch };
    }

    function storeData(key, index, exactState) {
        const value = `${index},${exactState ? 1 : 0}`;
        localStorage.setItem(key, value);
    }

    function exportFavorites() {
        const favorites = {};
        for (let i = 0; i < localStorage.length; i++) {
            const key = localStorage.key(i);
            favorites[key] = localStorage.getItem(key);
        }
        const blob = new Blob([JSON.stringify(favorites, null, 2)], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = 'favorites.json';
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);
    }

    function importFavorites(event) {
        const file = event.target.files[0];
        if (!file) return;

        const reader = new FileReader();
        reader.onload = function(e) {
            try {
                const favorites = JSON.parse(e.target.result);
                for (const key in favorites) {
                    localStorage.setItem(key, favorites[key]);
                }
                alert('Favorites imported successfully!');
            } catch (error) {
                alert('Error importing favorites:', error);
            }
        };
        reader.readAsText(file);
    }

    function parseVocabFromAnswer() {
        const elements = document.querySelectorAll('a[href*="/kanji/"], a[href*="/vocabulary/"]');
        for (const element of elements) {
            const href = element.getAttribute('href');
            const text = element.textContent.trim();
            const match = href.match(/\/(kanji|vocabulary)\/(?:\d+\/)?([^\#]*)#/);
            if (match) return match[2].trim();
            if (text) return text.trim();
        }
        return '';
    }

    function parseVocabFromReview() {
        const kindElement = document.querySelector('.kind');
        if (!kindElement) return '';

        const kindText = kindElement.textContent.trim();
        if (kindText !== 'Kanji' && kindText !== 'Vocabulary') return '';

        if (kindText === 'Vocabulary') {
            const plainElement = document.querySelector('.plain');
            if (!plainElement) return '';

            let vocabulary = plainElement.textContent.trim();
            const nestedVocabularyElement = plainElement.querySelector('div:not([style])');
            if (nestedVocabularyElement) {
                vocabulary = nestedVocabularyElement.textContent.trim();
            }
            const specificVocabularyElement = plainElement.querySelector('div:nth-child(3)');
            if (specificVocabularyElement) {
                vocabulary = specificVocabularyElement.textContent.trim();
            }

            const kanjiRegex = /[\u4e00-\u9faf\u3400-\u4dbf]/;
            if (kanjiRegex.test(vocabulary) || vocabulary) {
                return vocabulary;
            }
        } else if (kindText === 'Kanji') {
            const hiddenInput = document.querySelector('input[name="c"]');
            if (!hiddenInput) return '';

            const vocab = hiddenInput.value.split(',')[1];
            const kanjiRegex = /[\u4e00-\u9faf\u3400-\u4dbf]/;
            if (kanjiRegex.test(vocab)) {
                return vocab;
            }
        }
        return '';
    }

    function parseVocabFromVocabulary() {
        const url = window.location.href;
        const match = url.match(/https:\/\/jpdb\.io\/vocabulary\/(\d+)\/([^\#]*)#a/);
        if (match) {
            let vocab = match[2];
            state.embedAboveSubsectionMeanings = true;
            vocab = vocab.split('/')[0];
            return decodeURIComponent(vocab);
        }
        return '';
    }

    function parseVocabFromKanji() {
        const url = window.location.href;
        const match = url.match(/https:\/\/jpdb\.io\/kanji\/([^#]*)#a/);
        if (match) {
            return decodeURIComponent(match[1]);
        }
        return '';
    }

    function highlightVocab(sentence, vocab) {
        if (!CONFIG.COLORED_SENTENCE_TEXT) return sentence;

        if (state.exactSearch) {
            const regex = new RegExp(`(${vocab})`, 'g');
            return sentence.replace(regex, '<span style="color: var(--outline-input-color);">$1</span>');
        } else {
            return vocab.split('').reduce((acc, char) => {
                const regex = new RegExp(char, 'g');
                return acc.replace(regex, `<span style="color: var(--outline-input-color);">${char}</span>`);
            }, sentence);
        }
    }

    function createIconLink(iconClass, onClick, marginLeft = '0') {
        const link = document.createElement('a');
        link.href = '#';
        link.style.border = '0';
        link.style.display = 'inline-flex';
        link.style.verticalAlign = 'middle';
        link.style.marginLeft = marginLeft;

        const icon = document.createElement('i');
        icon.className = iconClass;
        icon.style.fontSize = '1.4rem';
        icon.style.opacity = '1.0';
        icon.style.verticalAlign = 'baseline';
        icon.style.color = '#3d81ff';

        link.appendChild(icon);
        link.addEventListener('click', onClick);
        return link;
    }

    function createQuoteButton() {
        const link = document.createElement('a');
        link.href = '#';
        link.style.border = '0';
        link.style.display = 'inline-flex';
        link.style.verticalAlign = 'middle';
        link.style.marginLeft = '0rem';

        const quoteIcon = document.createElement('span');
        quoteIcon.innerHTML = state.exactSearch ? '<b>「」</b>' : '『』';
        quoteIcon.style.fontSize = '1.1rem';
        quoteIcon.style.color = '#3D8DFF';
        quoteIcon.style.verticalAlign = 'middle';
        quoteIcon.style.position = 'relative';
        quoteIcon.style.top = '0px';

        link.appendChild(quoteIcon);
        link.addEventListener('click', (event) => {
            event.preventDefault();
            state.exactSearch = !state.exactSearch;
            quoteIcon.innerHTML = state.exactSearch ? '<b>「」</b>' : '『』';

            const storedData = getStoredData(state.vocab);
            if (storedData && storedData.exactState === state.exactSearch) {
                state.currentExampleIndex = storedData.index;
            } else {
                state.currentExampleIndex = 0;
            }

            getImmersionKitData(state.vocab, state.exactSearch, () => {
                embedImageAndPlayAudio();
            });
        });
        return link;
    }

    function createStarLink() {
        const link = document.createElement('a');
        link.href = '#';
        link.style.border = '0';
        link.style.display = 'inline-flex';
        link.style.verticalAlign = 'middle';

        const storedData = getStoredData(state.vocab);
        const starIcon = document.createElement('span');

        if (!localStorage.getItem(state.vocab)) {
            starIcon.textContent = '☆';
        } else {
            starIcon.textContent = (state.currentExampleIndex === storedData.index && state.exactSearch === storedData.exactState) ? '★' : '☆';
        }


        starIcon.style.fontSize = '1.4rem';
        starIcon.style.marginLeft = '0.5rem';
        starIcon.style.color = '#3D8DFF';
        starIcon.style.verticalAlign = 'middle';
        starIcon.style.position = 'relative';
        starIcon.style.top = '-2px';

        link.appendChild(starIcon);
        link.addEventListener('click', (event) => {
            event.preventDefault();
            const favoriteData = getStoredData(state.vocab);
            if (favoriteData && favoriteData.index === state.currentExampleIndex && favoriteData.exactState === state.exactSearch) {
                localStorage.removeItem(state.vocab);
            } else {
                storeData(state.vocab, state.currentExampleIndex, state.exactSearch);
            }
            embedImageAndPlayAudio();
        });
        return link;
    }

    function createExportImportButtons() {
        const exportButton = document.createElement('button');
        exportButton.textContent = 'Export Favorites';
        exportButton.style.marginRight = '10px';
        exportButton.addEventListener('click', exportFavorites);

        const importButton = document.createElement('button');
        importButton.textContent = 'Import Favorites';
        importButton.addEventListener('click', () => {
            const fileInput = document.createElement('input');
            fileInput.type = 'file';
            fileInput.accept = 'application/json';
            fileInput.addEventListener('change', importFavorites);
            fileInput.click();
        });

        const buttonContainer = document.createElement('div');
        buttonContainer.style.textAlign = 'center';
        buttonContainer.style.marginTop = '10px';
        buttonContainer.append(exportButton, importButton);

        document.body.appendChild(buttonContainer);
    }

    function playAudio(soundUrl) {
        if (soundUrl) {
            // Stop the current audio if it's playing
            if (state.currentAudio) {
                state.currentAudio.pause();
                state.currentAudio.src = '';
            }

            // Fetch the audio file as a blob and create a blob URL
            GM_xmlhttpRequest({
                method: 'GET',
                url: soundUrl,
                responseType: 'blob',
                onload: function(response) {
                    const blobUrl = URL.createObjectURL(response.response);
                    const audioElement = new Audio(blobUrl);
                    audioElement.volume = CONFIG.SOUND_VOLUME; // Adjust volume as needed
                    audioElement.play();
                    state.currentAudio = audioElement; // Keep reference to the current audio
                },
                onerror: function(error) {
                    console.error('Error fetching audio:', error);
                }
            });
        }
    }

    function renderImageAndPlayAudio(vocab, shouldAutoPlaySound) {
        const example = state.examples[state.currentExampleIndex] || {};
        const imageUrl = example.image_url || null;
        const soundUrl = example.sound_url || null;
        const sentence = example.sentence || null;

        const resultVocabularySection = document.querySelector('.result.vocabulary');
        const hboxWrapSection = document.querySelector('.hbox.wrap');
        const subsectionMeanings = document.querySelector('.subsection-meanings');
        const subsectionComposedOfKanji = document.querySelector('.subsection-composed-of-kanji');
        const subsectionPitchAccent = document.querySelector('.subsection-pitch-accent');
        const subsectionLabels = document.querySelectorAll('h6.subsection-label');
        const vboxGap = document.querySelector('.vbox.gap');

        // Remove any existing container before creating a new one
        const existingContainer = document.getElementById('immersion-kit-container');
        if (existingContainer) {
            existingContainer.remove();
        }

        if (resultVocabularySection || hboxWrapSection || subsectionMeanings || subsectionLabels.length >= 3) {
            const wrapperDiv = document.createElement('div');
            wrapperDiv.id = 'image-wrapper';
            wrapperDiv.style.textAlign = 'center';
            wrapperDiv.style.padding = '5px 0';

            const textDiv = document.createElement('div');
            textDiv.style.marginBottom = '5px';
            textDiv.style.lineHeight = '1.4rem';

            const contentText = document.createElement('span');
            contentText.textContent = 'Immersion Kit';
            contentText.style.color = 'var(--subsection-label-color)';
            contentText.style.fontSize = '85%';
            contentText.style.marginRight = '0.5rem';
            contentText.style.verticalAlign = 'middle';

            const speakerLink = createIconLink('ti ti-volume', (event) => {
                event.preventDefault();
                playAudio(soundUrl);
            }, '0.5rem');

            const starLink = createStarLink();
            const quoteButton = createQuoteButton(); // Create the quote button

            textDiv.append(contentText, speakerLink, starLink, quoteButton); // Append the quote button
            wrapperDiv.appendChild(textDiv);

            if (imageUrl) {
                const imageElement = GM_addElement(wrapperDiv, 'img', {
                    src: imageUrl,
                    alt: 'Embedded Image',
                    style: `max-width: ${CONFIG.IMAGE_WIDTH}; margin-top: 10px; cursor: pointer;`
            });

            if (imageElement) {
                imageElement.addEventListener('click', () => {
                    speakerLink.click();
                });
            }

            if (sentence) {
                const sentenceText = document.createElement('div');
                sentenceText.innerHTML = highlightVocab(sentence, vocab);
                sentenceText.style.marginTop = '10px';
                sentenceText.style.fontSize = CONFIG.SENTENCE_FONT_SIZE;
                sentenceText.style.color = 'lightgray';
                sentenceText.style.maxWidth = CONFIG.IMAGE_WIDTH;
                sentenceText.style.whiteSpace = 'pre-wrap';
                wrapperDiv.appendChild(sentenceText);

                if (CONFIG.ENABLE_EXAMPLE_TRANSLATION && example.translation) {
                    const translationText = document.createElement('div');
                    translationText.innerHTML = replaceSpecialCharacters(example.translation);
                    translationText.style.marginTop = '5px';
                    translationText.style.fontSize = CONFIG.TRANSLATION_FONT_SIZE;
                    translationText.style.color = 'var(--subsection-label-color)';
                    translationText.style.maxWidth = CONFIG.IMAGE_WIDTH;
                    translationText.style.whiteSpace = 'pre-wrap';
                    wrapperDiv.appendChild(translationText);
                }
            } else {
                const noneText = document.createElement('div');
                noneText.textContent = 'None';
                noneText.style.marginTop = '10px';
                noneText.style.fontSize = '85%';
                noneText.style.color = 'var(--subsection-label-color)';
                wrapperDiv.appendChild(noneText);
            }
        }

        // Create a fixed-width container for arrows and image
        const navigationDiv = document.createElement('div');
        navigationDiv.id = 'immersion-kit-embed';
        navigationDiv.style.display = 'flex';
        navigationDiv.style.justifyContent = 'center';
        navigationDiv.style.alignItems = 'center';
        navigationDiv.style.maxWidth = CONFIG.IMAGE_WIDTH;
        navigationDiv.style.margin = '0 auto';

        const leftArrow = document.createElement('button');
        leftArrow.textContent = '<';
        leftArrow.style.marginRight = '10px';
        leftArrow.disabled = state.currentExampleIndex === 0;
        leftArrow.addEventListener('click', () => {
            if (state.currentExampleIndex > 0) {
                state.currentExampleIndex--;
                renderImageAndPlayAudio(vocab, shouldAutoPlaySound);
                preloadImages();
            }
        });

        const rightArrow = document.createElement('button');
        rightArrow.textContent = '>';
        rightArrow.style.marginLeft = '10px';
        rightArrow.disabled = state.currentExampleIndex >= state.examples.length - 1;
        rightArrow.addEventListener('click', () => {
            if (state.currentExampleIndex < state.examples.length - 1) {
                state.currentExampleIndex++;
                renderImageAndPlayAudio(vocab, shouldAutoPlaySound);
                preloadImages();
            }
        });

        const embedWrapper = document.createElement('div');
        embedWrapper.style.flex = '0 0 auto';
        embedWrapper.style.marginLeft = '10px';
        embedWrapper.appendChild(navigationDiv);

        const containerDiv = document.createElement('div');
        containerDiv.id = 'immersion-kit-container'; // Added ID for targeting
        containerDiv.style.display = 'flex';
        containerDiv.style.alignItems = 'center';
        containerDiv.style.justifyContent = CONFIG.WIDE_MODE ? 'flex-start' : 'center'; // Center if wide_mode is false
        containerDiv.append(leftArrow, wrapperDiv, rightArrow, embedWrapper);

        if (CONFIG.WIDE_MODE && subsectionMeanings) {
            const wrapper = document.createElement('div');
            wrapper.style.display = 'flex';
            wrapper.style.alignItems = 'flex-start';

            const originalContentWrapper = document.createElement('div');
            originalContentWrapper.style.flex = '1';
            originalContentWrapper.appendChild(subsectionMeanings);

            // Append the new subsections with a newline before each
            if (subsectionComposedOfKanji) {
                const newline1 = document.createElement('br');
                originalContentWrapper.appendChild(newline1);
                originalContentWrapper.appendChild(subsectionComposedOfKanji);
            }
            if (subsectionPitchAccent) {
                const newline2 = document.createElement('br');
                originalContentWrapper.appendChild(newline2);
                originalContentWrapper.appendChild(subsectionPitchAccent);
            }

            wrapper.appendChild(originalContentWrapper);
            wrapper.appendChild(containerDiv);

            if (vboxGap) {
                // Remove only the dynamically added div under vboxGap
                const existingDynamicDiv = vboxGap.querySelector('#dynamic-content');
                if (existingDynamicDiv) {
                    existingDynamicDiv.remove();
                }

                const dynamicDiv = document.createElement('div');
                dynamicDiv.id = 'dynamic-content';
                dynamicDiv.appendChild(wrapper);

                // Insert after the first child if the URL contains vocabulary
                if (window.location.href.includes('vocabulary')) {
                    vboxGap.insertBefore(dynamicDiv, vboxGap.children[1]);
                } else {
                    vboxGap.insertBefore(dynamicDiv, vboxGap.firstChild); // Insert at the top
                }
            }
        } else {
            if (state.embedAboveSubsectionMeanings && subsectionMeanings) {
                subsectionMeanings.parentNode.insertBefore(containerDiv, subsectionMeanings);
            } else if (resultVocabularySection) {
                resultVocabularySection.parentNode.insertBefore(containerDiv, resultVocabularySection);
            } else if (hboxWrapSection) {
                hboxWrapSection.parentNode.insertBefore(containerDiv, hboxWrapSection);
            } else if (subsectionLabels.length >= 4) {
                subsectionLabels[3].parentNode.insertBefore(containerDiv, subsectionLabels[3]);
            }
        }
    }

    if (CONFIG.AUTO_PLAY_SOUND && shouldAutoPlaySound) {
        playAudio(soundUrl);
    }
}

    function embedImageAndPlayAudio() {
        const existingNavigationDiv = document.getElementById('immersion-kit-embed');
        if (existingNavigationDiv) existingNavigationDiv.remove();

        const reviewUrlPattern = /https:\/\/jpdb\.io\/review(#a)?$/;

        if (state.vocab && !state.apiDataFetched) {
            getImmersionKitData(state.vocab, state.exactSearch, () => {
                renderImageAndPlayAudio(state.vocab, !reviewUrlPattern.test(window.location.href));
                preloadImages();
            });
        } else {
            renderImageAndPlayAudio(state.vocab, !reviewUrlPattern.test(window.location.href));
            preloadImages();
        }
    }

    function replaceSpecialCharacters(text) {
        return text.replace(/<br>/g, '\n').replace(/&quot;/g, '"').replace(/\n/g, '<br>');
    }

    function preloadImages() {
        const preloadDiv = GM_addElement(document.body, 'div', { style: 'display: none;' });

        for (let i = Math.max(0, state.currentExampleIndex - CONFIG.NUM_PRELOADS); i <= Math.min(state.examples.length - 1, state.currentExampleIndex + CONFIG.NUM_PRELOADS); i++) {
            if (!state.preloadedIndices.has(i)) {
                const example = state.examples[i];
                if (example.image_url) {
                    GM_addElement(preloadDiv, 'img', { src: example.image_url });
                    state.preloadedIndices.add(i);
                }
            }
        }
    }

    function onUrlChange() {
        state.embedAboveSubsectionMeanings = false;
        if (window.location.href.includes('/vocabulary/')) {
            state.vocab = parseVocabFromVocabulary();
        } else if (window.location.href.includes('c=')) {
            state.vocab = parseVocabFromAnswer();
        } else if (window.location.href.includes('/kanji/')) {
            state.vocab = parseVocabFromKanji();
        } else {
            state.vocab = parseVocabFromReview();
        }

        const storedData = getStoredData(state.vocab);
        state.currentExampleIndex = storedData.index;
        state.exactSearch = storedData.exactState;

        const reviewUrlPattern = /https:\/\/jpdb\.io\/review(#a)?$/;
        const shouldAutoPlaySound = !reviewUrlPattern.test(window.location.href);

        if (state.vocab) {
            embedImageAndPlayAudio();
        }
    }

    const observer = new MutationObserver(() => {
        if (window.location.href !== observer.lastUrl) {
            observer.lastUrl = window.location.href;
            onUrlChange();

            setPageWidth();
        }
    });

    function setPageWidth(){
        if (CONFIG.WIDE_MODE) {
            document.body.style.maxWidth = CONFIG.PAGE_MAX_WIDTH;
        } };

    observer.lastUrl = window.location;
    observer.lastUrl = window.location.href;
    observer.observe(document, { subtree: true, childList: true });

    window.addEventListener('load', onUrlChange);
    window.addEventListener('popstate', onUrlChange);
    window.addEventListener('hashchange', onUrlChange);


    setPageWidth();
    createExportImportButtons();
})();