Wanikani Anime Sentences 2

Adds example sentences from anime movies and shows for vocabulary from immersionkit.com

当前为 2024-05-20 提交的版本,查看 最新版本

// ==UserScript==
// @name         Wanikani Anime Sentences 2
// @description  Adds example sentences from anime movies and shows for vocabulary from immersionkit.com
// @version      1.2.0
// @author       psdcon, edited by Inserio
// @namespace    wkanimesentences/inserio
// @match        https://www.wanikani.com/*
// @match        https://preview.wanikani.com/*
// @require      https://gf.qytechs.cn/scripts/430565-wanikani-item-info-injector/code/WaniKani%20Item%20Info%20Injector.user.js?version=1343973
// @copyright    2021+, Paul Connolly
// @license      MIT; http://opensource.org/licenses/MIT
// @run-at       document-end
// @grant        none
// ==/UserScript==

(() => {

    //--------------------------------------------------------------------------------------------------------------//
    //-----------------------------------------------INITIALIZATION-------------------------------------------------//
    //--------------------------------------------------------------------------------------------------------------//
    const wkof = window.wkof;

    const scriptId = "anime-sentences";
    const scriptName = "Anime Sentences";

    let state = {
        settings: {
            playbackRate: 1,
            exampleLimit: 100,
            showEnglish: 'onhover',
            showJapanese: 'always',
            showFurigana: 'onhover',
            sentenceSorting: 'shortness',
            filterExactSearch: true,
            filterJLPTLevel: 0,
            filterWaniKaniLevel: true,
            // Enable all shows and movies by default
            filterAnimeShows: {0: true, 1: true, 2: true, 3: true, 4: true, 5: true, 6: true, 7: true, 8: true, 9: true,
                               10: true, 11: true, 12: true, 13: true, 14: true, 15: true, 16: true, 17: true, 18: true, 19: true,
                               20: true, 21: true, 22: true, 23: true, 24: true, 25: true, 26: true, 27: true, 28: true, 29: true,
                               30: true, 31: true, 32: true, 33: true, 34: true, 35: true, 36: true, 37: true, 38: true, 39: true,
                               40: true, 41: true, 42: true, 43: true, 44: true, 45: true, 46: true, 47: true, 48: true, 49: true,
                               50: true, 51: true, 52: true, 53: true},
            filterAnimeMovies: {0: true, 1: true, 2: true, 3: true, 4: true, 5: true, 6: true},
            filterGhibli: {0: true, 1: true, 2: true, 3: true, 4: true, 5: true, 6: true, 7: true, 8: true, 9: true, 10: true, 11: true, 12: true},
        },
        item: null, // current vocab from wkinfo
        userLevel: '', // most recent level progression
        immersionKitData: null, // cached so sentences can be re-rendered after settings change
        sentencesEl: null, // referenced so sentences can be re-rendered after settings change
    };

    // Titles taken from https://www.immersionkit.com/information and modified after testing a few example search results
    const animeShows = {
        0: "Angel Beats!",
        1: "Anohana the flower we saw that day",
        2: "Assassination Classroom Season 1",
        3: "Bakemonogatari",
        4: "Bunny Drop",
        5: "Boku no Hero Academia Season 1",
        6: "Cardcaptor Sakura",
        7: "Chobits",
        8: "Clannad",
        9: "Clannad After Story",
        10: "Code Geass Season 1",
        11: "Daily Lives of High School Boys",
        12: "Death Note",
        13: "Demon Slayer - Kimetsu no Yaiba",
        14: "Durarara!!",
        15: "Erased",
        16: "Fairy Tail",
        17: "Fate Stay Night Unlimited Blade Works",
        18: "Fate Zero",
        19: "From the New World",
        20: "Fruits Basket Season 1",
        21: "Fullmetal Alchemist Brotherhood",
        22: "God's Blessing on this Wonderful World!",
        23: "Haruhi Suzumiya",
        24: "Hunter × Hunter",
        25: "Hyouka",
        26: "Is The Order a Rabbit",
        27: "K-On!",
        28: "Kakegurui",
        29: "Kanon (2006)",
        30: "Kill la Kill",
        31: "Kino's Journey",
        32: "Kokoro Connect",
        33: "Little Witch Academia",
        34: "Lucky Star",
        35: "Mahou Shoujo Madoka Magica",
        36: "Mononoke",
        37: "My Little Sister Can't Be This Cute",
        38: "New Game!",
        39: "Nisekoi",
        40: "No Game No Life",
        41: "Noragami",
        42: "One Week Friends",
        43: "Psycho Pass",
        44: "Re:Zero − Starting Life in Another World",
        45: "ReLIFE",
        46: "Shirokuma Cafe",
        47: "Sound! Euphonium",
        48: "Steins Gate",
        49: "Sword Art Online",
        50: "The Pet Girl of Sakurasou",
        51: "Toradora!",
        52: "Wandering Witch The Journey of Elaina",
        53: "Your Lie in April",
    };

    const animeMovies = {
        0: "Only Yesterday",
        1: "The Garden of Words",
        2: "The Girl Who Leapt Through Time",
        3: "The World God Only Knows",
        4: "Weathering with You",
        5: "Wolf Children",
        6: "Your Name",
    };

    const ghibliTitles = {
        0: "Castle in the sky",
        1: "From Up on Poppy Hill",
        2: "Grave of the Fireflies",
        3: "Howl's Moving Castle",
        4: "Kiki's Delivery Service",
        5: "My Neighbor Totoro",
        6: "Princess Mononoke",
        7: "Spirited Away",
        8: "The Cat Returns",
        9: "The Secret World of Arrietty",
        10: "The Wind Rises",
        11: "When Marnie Was There",
        12: "Whisper of the Heart",
    };

    main();

    function main() {
        init(() => wkItemInfo.forType(`vocabulary,kanaVocabulary`).under(`examples`).notify(
            (item) => onExamplesVisible(item))
        );
    }

    function init(callback) {
        createStyle();

        if (wkof) {
            wkof.include("ItemData,Settings");
            wkof
                .ready("Apiv2,Settings")
                .then(loadSettings)
                .then(processLoadedSettings)
                .then(getLevel)
                .then(callback);
        } else {
            console.warn(
                `${scriptName}: You are not using Wanikani Open Framework which this script utilizes to provide the settings dialog for the script. You can still use ${scriptName} normally though`
            );
            callback();
        }
    }

    function getLevel() {
        wkof.Apiv2.fetch_endpoint('level_progressions', (window.unsafeWindow ?? window).options ?? analyticsOptions).then((response) => {
            state.userLevel = response.data[response.data.length - 1].data.level;
        });
    }

    function onExamplesVisible(item) {
        state.item = item; // current vocab item
        addAnimeSentences();
    }

    function addAnimeSentences() {
        const parentEl = document.createElement("div");
        parentEl.setAttribute("id", 'anime-sentences-parent');

        let header = ['Anime Sentences'];

        const settingsBtn = document.createElement("svg");
        settingsBtn.setAttribute("style", "font-size: 14px; cursor: pointer; vertical-align: middle; margin-left: 10px;");
        settingsBtn.textContent = '⚙️';
        settingsBtn.onclick = openSettings;
        let sentencesEl = document.createElement("div");
        sentencesEl.innerText = 'Loading...';

        header.push(settingsBtn);
        parentEl.append(sentencesEl);
        state.sentencesEl = sentencesEl;

        if (state.item.injector) {
            if (state.item.on === 'lesson') {
                state.item.injector.appendAtTop(header, parentEl);
            } else { // itemPage, review
                state.item.injector.append(header, parentEl);
            }
        }

        fetchImmersionKitData().then(examples => {
            renderSentences(examples);
        });
    }

    function fetchImmersionKitData() {
        let keyword = state.item.characters.replace('〜', '');  // for "counter" kanji
        if (state.settings.filterExactSearch) {
            keyword = `「${keyword}」`;
        }
        const tags = ''; // TODO: &tags=
        const sentenceSorting = state.settings.sentenceSorting !== 'none' ? `&sort=${state.settings.sentenceSorting}` : '';
        const jlptFilter = state.settings.filterJLPTLevel !== 0 ? `&jlpt=${state.settings.filterJLPTLevel}` : '';
        const wkLevelFilter = state.settings.filterWaniKaniLevel ? `&wk=${state.userLevel}` : '';
        const url = `https://api.immersionkit.com/look_up_dictionary?keyword=${keyword}&category=anime${sentenceSorting}${tags}${jlptFilter}${wkLevelFilter}`;
        return fetch(url)
            .then(response => response.json())
            .then(data => {
                return data.data[0].examples;
            });
    }

    function getDesiredShows() {
        // Convert settings dictionaries to array of titles
        let titles = [];
        for (const [key, value] of Object.entries(state.settings.filterAnimeShows)) {
            if (value === true) {
                titles.push(animeShows[key]);
            }
        }
        for (const [key, value] of Object.entries(state.settings.filterAnimeMovies)) {
            if (value === true) {
                titles.push(animeMovies[key]);
            }
        }
        for (const [key, value] of Object.entries(state.settings.filterGhibli)) {
            if (value === true) {
                titles.push(ghibliTitles[key]);
            }
        }
        return titles;
    }

    function renderSentences(sentences) {
        // Called from immersionkit response, and on settings save
        const exampleLenBeforeFilter = sentences.length;

        // Exclude non-selected titles
        let desiredTitles = getDesiredShows();
        let examples = [];
        for (let i=0; i<sentences.length; i++) {
            let ex = sentences[i];
            if (desiredTitles.includes(ex.deck_name))
                examples.push(ex);
        }
        // This is necessary until the API is fixed to allow the sorting preference to happen before the deck sorting
        switch (state.settings.sentenceSorting) {
            case 'shortness':
                examples.sort((a, b) => a.sentence.length - b.sentence.length);
                break;
            case 'longness':
                examples.sort((a, b) => b.sentence.length - a.sentence.length);
                break;
        }

        let showJapanese = state.settings.showJapanese;
        let showEnglish = state.settings.showEnglish;
        let showFurigana = state.settings.showFurigana;
        let playbackRate = state.settings.playbackRate;

        let html = '';
        let exampleLimit = Math.min(examples.length, state.settings.exampleLimit);

        if (exampleLenBeforeFilter === 0) {
            html = 'No sentences found.';
        } else if (examples.length === 0 && exampleLenBeforeFilter > 0) {
            // TODO show which titles have how many examples
            html = 'No sentences found for your selected movies & shows.';
        } else {
            const keyword = state.item.characters.replace('〜', '');
            for (let i = 0; i < exampleLimit; i++) {
                const example = examples[i];

                let japaneseText;
                switch (state.settings.showFurigana) {
                    case 'never':
                        japaneseText = example.sentence.replace(keyword, `<keyword>${keyword}</keyword>`);
                        break;
                    default:
                        japaneseText = new Furigana(example.sentence_with_furigana, keyword, ['keyword']).ReadingHtml;
                        break;
                }

                html += `
    <div class="anime-example">
        <img src="${example.image_url}" alt="">
        <div class="anime-example-text">
            <div class="title" title="${example.id}">${example.deck_name}
            <span><button class="audio-btn audio-idle">🔈</button></span>
            </div>
            <div class="ja">
                <span class="${showJapanese === 'onhover' ? 'show-on-hover' : ''} ${showFurigana === 'onhover' ? 'show-ruby-on-hover' : ''}  ${showJapanese === 'onclick' ? 'show-on-click' : ''}">${japaneseText}</span>
            </div>
            <div class="en">
                <span class="${showEnglish === 'onhover' ? 'show-on-hover' : ''} ${showEnglish === 'onclick' ? 'show-on-click' : ''}">${example.translation}</span>
            </div>
        </div>
    </div>`
            }
        }

        let sentencesEl = state.sentencesEl;
        sentencesEl.innerHTML = html;

        const audioIdleClass = "audio-idle";
        const audioPlayClass = "audio-play";

        const animeSentencesContainer = document.querySelector("#anime-sentences-parent");
        const audioButtons = document.querySelectorAll(".anime-example .audio-btn");
        for (let i = 0; i < audioButtons.length; i++) {
            let audioContainer;
            const button = audioButtons[i];
            const onPlay = () => {button.classList.replace(audioIdleClass,audioPlayClass);button.textContent='🔊';};
            const onStop = () => {button.classList.replace(audioPlayClass,audioIdleClass);button.textContent='🔈';removeAudioElement(audioContainer);};
            button.onclick = function () {
                if ((audioContainer = document.querySelector("#anime-sentences-parent audio")) !== null) {
                    let prevSource = audioContainer.src;
                    audioContainer.pause();
                    if (prevSource === examples[i].sound_url) {
                        return;
                    }
                }
                audioContainer = document.createElement("audio");
                audioContainer.src = examples[i].sound_url;
                audioContainer.playbackRate = playbackRate;
                audioContainer.onplay = onPlay;
                audioContainer.onpause = onStop;
                audioContainer.onended = onStop;
                audioContainer.onabort = onStop;
                animeSentencesContainer.append(audioContainer);
			    audioContainer.play();
            };
        }

        // Click anywhere plays the audio
        let exampleEls = document.querySelectorAll("#anime-sentences-parent .anime-example");
        exampleEls.forEach((a) => {
            a.onclick = function () {
                let button = this.querySelector('.audio-btn');
                button.click();
            };
        });

        // Assigning onclick function to .show-on-click elements
        document.querySelectorAll(".show-on-click").forEach((a) => {
            a.onclick = function (e) {
                e.stopPropagation(); // prevent this click from triggering the audio to play
                a.classList.toggle('show-on-click');
            };
        });
    }

    function removeAudioElement(element) {
        if (element === undefined || element === null) {
            return;
        }
        element.src = "";
        element.remove();
    }

    //--------------------------------------------------------------------------------------------------------------//
    //----------------------------------------------SETTINGS--------------------------------------------------------//
    //--------------------------------------------------------------------------------------------------------------//

    function loadSettings() {
        return wkof.Settings.load(scriptId, state.settings);
    }

    function processLoadedSettings() {
        state.settings = wkof.settings[scriptId];
    }

    function openSettings(e) {
		e.stopPropagation();
        let config = {
            script_id: scriptId,
            title: scriptName,
            on_save: updateSettings,
            content: {
                general: {
                    type: "section",
                    label: "General"
                },
                sentenceSorting: {
                    type: "dropdown",
                    label: "Sentence Sorting Order",
                    hover_tip: "",
                    content: {
                        none: "Anime Title (alphabetical)",
                        shortness: "Shortest first",
                        longness: "Longest first"
                    },
                    default: state.settings.sentenceSorting
                },
                exampleLimit: {
                    type: "number",
                    label: "Example Limit",
                    step: 1,
                    min: 1,
                    hover_tip: "Limit the number of entries that may appear.",
                    default: state.settings.exampleLimit
                },
                playbackRate: {
                    type: "number",
                    label: "Playback Speed",
                    step: 0.1,
                    min: 0.5,
                    max: 2,
                    hover_tip: "Speed to play back audio.",
                    default: state.settings.playbackRate
                },
                showJapanese: {
                    type: "dropdown",
                    label: "Show Japanese",
                    hover_tip: "When to show Japanese text. Hover enables transcribing a sentences first (play audio by clicking the image to avoid seeing the answer).",
                    content: {
                        always: "Always",
                        onhover: "On Hover",
                        onclick: "On Click",
                    },
                    default: state.settings.showJapanese
                },
                showFurigana: {
                    type: "dropdown",
                    label: "Show Furigana",
                    hover_tip: "These have been autogenerated so there may be mistakes.",
                    content: {
                        always: "Always",
                        onhover: "On Hover",
                        never: "Never",
                    },
                    default: state.settings.showFurigana
                },
                showEnglish: {
                    type: "dropdown",
                    label: "Show English",
                    hover_tip: "Hover or click allows testing your understanding before seeing the answer.",
                    content: {
                        always: "Always",
                        onhover: "On Hover",
                        onclick: "On Click",
                    },
                    default: state.settings.showEnglish
                },
                tooltip: {
                    type: "section",
                    label: "Filters"
                },
                filterExactSearch: {
                    type: "checkbox",
                    label: "Exact Search",
                    hover_tip: "Text must match term exactly",
                    default: state.settings.filterExactSearch
                },
                filterGhibli: {
                    type: "list",
                    label: "Ghibli Movies",
                    multi: true,
                    size: 6,
                    hover_tip: "Select which Studio Ghibli movies you'd like to see examples from.",
                    default: state.settings.filterGhibli,
                    content: ghibliTitles
                },
                filterAnimeMovies: {
                    type: "list",
                    label: "Anime Movies",
                    multi: true,
                    size: 6,
                    hover_tip: "Select which anime movies you'd like to see examples from.",
                    default: state.settings.filterAnimeMovies,
                    content: animeMovies
                },
                filterAnimeShows: {
                    type: "list",
                    label: "Anime Shows",
                    multi: true,
                    size: 6,
                    hover_tip: "Select which anime shows you'd like to see examples from.",
                    default: state.settings.filterAnimeShows,
                    content: animeShows
                },
                filterJLPTLevel: {
                    type: "dropdown",
                    label: "JLPT Level",
                    hover_tip: "Only show sentences matching a particular JLPT Level or easier.",
                    content: {
                        0: "No Filter",
                        1: "N1",
                        2: "N2",
                        3: "N3",
                        4: "N4",
                        5: "N5"
                    },
                    default: state.settings.filterJLPTLevel,
                },
                filterWaniKaniLevel: {
                    type: "checkbox",
                    label: "WaniKani Level",
                    hover_tip: "Only show sentences with maximum 1 word outside of your current WaniKani level.",
                    default: state.settings.filterWaniKaniLevel,
                },
                credits: {
                    type: "section",
                    label: "Powered by immersionkit.com"
                },
            }
        };
        let dialog = new wkof.Settings(config);
        dialog.open();
    }

    // Called when the user clicks the Save button on the Settings dialog.
    function updateSettings() {
        state.settings = wkof.settings[scriptId];
        fetchImmersionKitData().then(examples => {
            renderSentences(examples);
        });
    }

    //--------------------------------------------------------------------------------------------------------------//
    //-----------------------------------------------STYLES---------------------------------------------------------//
    //--------------------------------------------------------------------------------------------------------------//

    function createStyle() {
        const style = document.createElement("style");
        style.setAttribute("id", "anime-sentences-style");
        // language=CSS
        style.innerHTML = `
            #anime-sentences-parent > div {
                overflow-y: auto;
                max-height: 280px;
            }

            #anime-sentences-parent .fa-solid {
                border: none;
                font-size: 100%;
            }

            .anime-example {
                display: flex;
                align-items: center;
                margin-bottom: 1em;
                cursor: pointer;
            }

            .anime-example .audio-btn {
                background-color: transparent;
            }

            .anime-example .audio-btn.audio-idle {
                opacity: 50%;
            }

            /* Make text and background color the same to hide text */
            .anime-example .anime-example-text .show-on-hover, .anime-example-text .show-on-click {
                background: #ccc;
                color: transparent;
                text-shadow: none;
            }

            .anime-example .anime-example-text .show-on-hover:hover {
                background: inherit;
                color: inherit
            }

            /* Color the keyword in the example sentence */
            .anime-example .anime-example-text .ja span keyword {
                color: darkcyan;
            }

            /* Furigana hover */
            .anime-example .anime-example-text .ja .show-ruby-on-hover ruby rt {
                visibility: hidden;
            }

            .anime-example .anime-example-text:hover .ja .show-ruby-on-hover ruby rt {
                visibility: visible;
            }

            .anime-example .title {
                font-weight: var(--font-weight-bold);
            }

            .anime-example .ja {
                font-size: var(--font-size-xlarge);
            }

            .anime-example img {
                margin-right: 1em;
                max-width: 200px;
            }
        `;

        document.querySelector("head").append(style);
    }


    //--------------------------------------------------------------------------------------------------------------//
    //----------------------------------------------FURIGANA--------------------------------------------------------//
    //--------------------------------------------------------------------------------------------------------------//
    // https://raw.githubusercontent.com/helephant/Gem/master/src/Gem.Javascript/gem.furigana.js
    function Furigana(reading, emphasisText, tags) {
        var segments = ParseFurigana(reading || "", emphasisText, tags);

        this.Reading = getReading();
        this.Expression = getExpression();
        this.Hiragana = getHiragana();
        this.ReadingHtml = getReadingHtml();

        function getReading() {
            var reading = "";
            for (var x = 0; x < segments.length; x++) {
                reading += segments[x].Reading;
            }
            return reading.trim();
        }

        function getExpression() {
            var expression = "";
            for (var x = 0; x < segments.length; x++)
                expression += segments[x].Expression;
            return expression;
        }

        function getHiragana() {
            var hiragana = "";
            for (var x = 0; x < segments.length; x++) {
                hiragana += segments[x].Hiragana;
            }
            return hiragana;
        }

        function getReadingHtml() {
            var html = "";
            for (var x = 0; x < segments.length; x++) {
                html += segments[x].ReadingHtml;
            }
            return html;
        }
    }

    function FuriganaSegment(baseText, furigana, emphasisText, tags) {
        this.Expression = baseText;
        this.Hiragana = furigana.trim();
        this.Reading = baseText + "[" + furigana + "]";
        let openTags = '';
        let closeTags = '';
        if (tags !== null) {
            for (let i = 0; i < tags.length; i++) {
                openTags = `${openTags}<${tags[i]}>`;
                closeTags = `</${tags[i]}>${closeTags}`;
            }
        }
        this.ReadingHtml = "<ruby><rb>" + baseText.replace(emphasisText, `${openTags}${emphasisText}${closeTags}`) + "</rb><rt>" + furigana + "</rt></ruby>";
    }

    function UndecoratedSegment(baseText) {
        this.Expression = baseText;
        this.Hiragana = baseText;
        this.Reading = baseText;
        this.ReadingHtml = baseText;
    }

    function ParseFurigana(reading, emphasisText, tags) {
        var segments = [];

        var currentBase = "";
        var currentFurigana = "";
        var parsingBaseSection = true;
        var parsingHtml = false;

        var characters = reading.split('');

        while (characters.length > 0) {
            var current = characters.shift();

            if (current === '[') {
                parsingBaseSection = false;
            } else if (current === ']') {
                nextSegment();
            } else if (isLastCharacterInBlock(current, characters) && parsingBaseSection) {
                currentBase += current;
                nextSegment();
            } else if (!parsingBaseSection)
                currentFurigana += current;
            else
                currentBase += current;
        }

        nextSegment();

        function nextSegment() {
            if (currentBase)
                segments.push(getSegment(currentBase, currentFurigana));
            currentBase = "";
            currentFurigana = "";
            parsingBaseSection = true;
            parsingHtml = false;
        }

        function getSegment(baseText, furigana) {
            if (!furigana || furigana.trim().length === 0)
                return new UndecoratedSegment(baseText);
            return new FuriganaSegment(baseText, furigana, emphasisText, tags);
        }

        function isLastCharacterInBlock(current, characters) {
            return !characters.length ||
                (isKanji(current) !== isKanji(characters[0]) && characters[0] !== '[');
        }

        function isKanji(character) {
            return character && character.charCodeAt(0) >= 0x4e00 && character.charCodeAt(0) <= 0x9faf;
        }

        return segments;
    }

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址