JPDB Immersion Kit Examples

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

Versione datata 08/09/2024. Vedi la nuova versione l'ultima versione.

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==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();
})();