JPDB Immersion Kit Examples

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

目前为 2024-09-08 提交的版本。查看 最新版本

// ==UserScript==
// @name         JPDB Immersion Kit Examples
// @version      1.5
// @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 storedValue = localStorage.getItem(state.vocab);
        const starIcon = document.createElement('span');

        if (!storedValue) {
            starIcon.textContent = '☆';
        } else {
            const [storedIndex, storedExactState] = storedValue.split(',');
            const index = parseInt(storedIndex, 10);
            const exactState = storedExactState === '1';
            starIcon.textContent = (state.currentExampleIndex === index && state.exactSearch === 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 storedValue = localStorage.getItem(state.vocab);
            if (storedValue) {
                const [storedIndex, storedExactState] = storedValue.split(',');
                const index = parseInt(storedIndex, 10);
                const exactState = storedExactState === '1';
                if (index === state.currentExampleIndex && exactState === state.exactSearch) {
                    localStorage.removeItem(state.vocab);
                } else {
                    localStorage.setItem(state.vocab, `${state.currentExampleIndex},${state.exactSearch ? 1 : 0}`);
                }
            } else {
                localStorage.setItem(state.vocab, `${state.currentExampleIndex},${state.exactSearch ? 1 : 0}`);
            }
            // Refresh the embed without playing the audio
            renderImageAndPlayAudio(state.vocab, false);
        });
        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();
})();