WaniKani Media Context Sentences

Formerly named "Wanikani Anime Sentences 2". Adds example sentences from anime, dramas, games, literature, and news for vocabulary from immersionkit.com.

// ==UserScript==
// @name         WaniKani Media Context Sentences
// @description  Formerly named "Wanikani Anime Sentences 2". Adds example sentences from anime, dramas, games, literature, and news for vocabulary from immersionkit.com.
// @version      3.0.3
// @author       Inserio
// @namespace    https://greasyfork.org/en/users/11878
// @match        https://www.wanikani.com/*
// @match        https://preview.wanikani.com/*
// @require      https://greasyfork.org/scripts/430565-wanikani-item-info-injector/code/WaniKani%20Item%20Info%20Injector.user.js?version=1416982
// @copyright    2021+, Paul Connolly
// @copyright    2024, Brian Shenk
// @license      MIT; http://opensource.org/licenses/MIT
// @run-at       document-body
// @grant        none
// ==/UserScript==
// Original script by psdcon
/* jshint esversion: 11 */
// noinspection CssUnusedSymbol,JSUnusedGlobalSymbols,JSNonASCIINames
/* global wkof, wkItemInfo */
(() => {
    'use strict';
    const wkof = window.wkof, oldScriptId = 'anime-sentences-2', scriptName = "Media Context Sentences", scriptId = scriptName.toLowerCase().replaceAll(' ', '-'), styleSheetName = `${scriptId}-style`;
    const state = {
        settings: {
            // The maximum height of the container box. If no unit type is provided, px (pixels) is automatically appended.
            maxBoxHeight: '320px',
            // 0 = No limit
            exampleLimit: 0,
            // Allows the box to appear in the Examples tab for kanji as well
            showOnKanji: false,
            // Options: always|onhover|onclick
            showJapanese: 'always',
            // Options: always|onhover|never
            showFurigana: 'onhover',
            // Options: always|onhover|onclick
            showEnglish: 'onhover',
            // Options: exact|fuzzy // TODO: Implement
            highlighting: 'exact',
            // Playback speed in percent = (playbackRate * 2)
            playbackRate: 50,
            // Playback volume in percent
            playbackVolume: 100,
            // If greater than 0, will attempt to retry fetching results if none were found.
            fetchRetryCount: 0,
            // Milliseconds to wait before retrying fetch
            fetchRetryDelay: 5000,
            // Options: default|category|shortness|longness|position
            sentenceSorting: 'default',
            // Options: default(none)|category|shortness|longness|position
            sentenceSortingSecondary: 'default',
            // Filters the results to only those with sentences exactly matching the keyword (i.e., this filters after the results are found)
            // TODO: Make the kanji (i.e., not the okurigana) required by default for non-kana-only vocab
            filterExactMatch: false,
            // Wraps the search term in Japanese quotes (i.e.,「term」) before sending it to Immersion Kit
            filterExactSearch: false,
            // Tells Immersion Kit to filter out results containing words more than 1 level higher than your current WaniKani level (possibly inaccurate, due to frequent changes in WK level contents)
            filterWaniKaniLevel: false,
            // If greater than 0, tells Immersion Kit to filter out results that are not at the selected JLPT level or easier.
            filterJLPTLevel: 0,
            // Mapping of the content title to the enabled state. All content is enabled by default.
            // Titles taken from https://www.immersionkit.com/information and modified after testing a few example search results.
            filterAnimeShows: {
                'Alya Sometimes Hides Her Feelings in Russian': true,
                'Angel Beats!': true,
                'Anohana the flower we saw that day': true,
                'Assassination Classroom Season 1': true,
                'Bakemonogatari': true,
                'Boku no Hero Academia Season 1': true,
                'Bunny Drop': true,
                'Cardcaptor Sakura': true,
                'Chobits': true,
                'Clannad': true,
                'Clannad After Story': true,
                'Code Geass Season 1': true,
                'Daily Lives of High School Boys': true,
                'Death Note': true,
                'Demon Slayer - Kimetsu no Yaiba': true,
                'Durarara!!': true,
                'Erased': true,
                'Fairy Tail': true,
                'Fate Stay Night Unlimited Blade Works': true,
                'Fate Zero': true,
                'From the New World': true,
                'Fruits Basket Season 1': true,
                'Fullmetal Alchemist Brotherhood': true,
                "God's Blessing on this Wonderful World!": true,
                'Haruhi Suzumiya': true,
                'Hunter × Hunter': true,
                'Hyouka': true,
                'Is The Order a Rabbit': true,
                'K-On!': true,
                'Kakegurui': true,
                'Kanon (2006)': true,
                'Kill la Kill': true,
                "Kino's Journey": true,
                'Kokoro Connect': true,
                'Little Witch Academia': true,
                'Lucky Star': true,
                'Mahou Shoujo Madoka Magica': true,
                'Mononoke': true,
                "My Little Sister Can't Be This Cute": true,
                'New Game!': true,
                'Nisekoi': true,
                'No Game No Life': true,
                'Noragami': true,
                'One Week Friends': true,
                'Psycho Pass': true,
                'Re Zero − Starting Life in Another World': true,
                'ReLIFE': true,
                'Shirokuma Cafe': true,
                'Sound! Euphonium': true,
                'Steins Gate': true,
                'Sword Art Online': true,
                'The Pet Girl of Sakurasou': true,
                'Toradora!': true,
                'Wandering Witch The Journey of Elaina': true,
                'Your Lie in April': true,
            },
            filterAnimeMovies: {
                'The Garden of Words': true,
                'The Girl Who Leapt Through Time': true,
                'The World God Only Knows': true,
                'Weathering with You': true,
                'Wolf Children': true,
                'Your Name': true,
            },
            filterGhibli: {
                'Castle in the sky': true,
                'From Up on Poppy Hill': true,
                'Grave of the Fireflies': true,
                "Howl's Moving Castle": true,
                "Kiki's Delivery Service": true,
                'My Neighbor Totoro': true,
                'Only Yesterday': true,
                'Princess Mononoke': true,
                'Spirited Away': true,
                'The Cat Returns': true,
                'The Secret World of Arrietty': true,
                'The Wind Rises': true,
                'When Marnie Was There': true,
                'Whisper of the Heart': true,
            },
            filterDramas: {
                '1 Litre of Tears': true,
                'Border': true,
                'Good Morning Call Season 1': true,
                'Good Morning Call Season 2': true,
                'I am Mita, Your Housekeeper': true,
                "I'm Taking the Day Off": true,
                'Legal High Season 1': true,
                'Million Yen Woman': true,
                'Mob Psycho 100': true,
                'Overprotected Kahoko': true,
                'Quartet': true,
                'Sailor Suit and Machine Gun (2006)': true,
                'Smoking': true,
                'The Journalist': true,
                'Weakest Beast': true,
            },
            filterGames: {
                'Cyberpunk 2077': true, 'Skyrim': true, 'Witcher 3': true,
                // The following are currently not queryable via the API (but maybe they will be someday?)
                // "NieR: Automata": true, "NieR Re[in]carnation": true, "Zelda: Breath of the Wild": true,
            },
            filterLiterature: {
                '黒猫': true,
                'おおかみと七ひきのこどもやぎ': true,
                'マッチ売りの少女': true,
                'サンタクロースがやってきた': true,
                '君死にたまふことなかれ': true,
                '蝉': true,
                '胡瓜': true,
                '若鮎について': true,
                '黒足袋': true,
                '柿': true,
                'お母さんの思ひ出': true,
                '砂をかむ': true,
                '虻のおれい': true,
                'がちゃがちゃ': true,
                '犬のいたずら': true,
                '犬と人形': true,
                '懐中時計': true,
                'きのこ会議': true,
                'お金とピストル': true,
                '梅のにおい': true,
                '純真': true,
                '声と人柄': true,
                '心の調べ': true,
                '愛': true,
                '期待と切望': true,
                '空の美': true,
                'いちょうの実': true,
                '虔十公園林': true,
                'クねずみ': true,
                'おきなぐさ': true,
                'さるのこしかけ': true,
                'セロ弾きのゴーシュ': true,
                'ざしき童子のはなし': true,
                '秋の歌': true,
                '赤い船とつばめ': true,
                '赤い蝋燭と人魚': true,
                '赤い魚と子供': true,
                '秋が きました': true,
                '青いボタン': true,
                'ある夜の星たちの話': true,
                'いろいろな花': true,
                'からすとかがし': true,
                '片田舎にあった話': true,
                '金魚売り': true,
                '小鳥と兄妹': true,
                'おじいさんが捨てたら': true,
                'おかめどんぐり': true,
                'お母さん': true,
                'お母さんのお乳': true,
                'おっぱい': true,
                '少年と秋の日': true,
                '金のくびかざり': true,
                '愛よ愛': true,
                '気の毒な奥様': true,
                '新茶': true,
                '初夏に座す': true,
                '三角と四角': true,
                '赤い蝋燭': true,
                '赤とんぼ': true,
                '飴だま': true,
                'あし': true,
                'がちょうのたんじょうび': true,
                'ごん狐': true,
                '蟹のしょうばい': true,
                'カタツムリノ ウタ': true,
                '木の祭り': true,
                'こぞうさんのおきょう': true,
                '去年の木': true,
                'おじいさんのランプ': true,
                '王さまと靴屋': true,
                '落とした一銭銅貨': true,
                'サルト サムライ': true,
                '里の春、山の春': true,
                'ウサギ 新美 南吉': true,
                'あひるさん と 時計': true,
                '川へおちた玉ねぎさん': true,
                '小ぐまさんのかんがへちがひ': true,
                'お鍋とお皿とカーテン': true,
                'お鍋とおやかんとフライパンのけんくわ': true,
                'ひらめの学校': true,
                '狐物語': true,
                '桜の樹の下には': true,
                '瓜子姫子': true,
                'ああしんど': true,
                '葬式の行列': true,
                '風': true,
                '子どものすきな神さま': true,
                '喫茶店にて': true,
                '子供に化けた狐': true,
                '顔': true,
                '四季とその折々': true,
            },
            filterNews: {
                '平成30年阿蘇神社で甘酒の仕込み始まる': true,
                'フレッシュマン!5月号阿蘇広域行政事務組合': true,
                'フレッシュマン!7月号春工房、そば処ゆう雀': true,
                'フレッシュマン!11月号内牧保育園': true,
                '山田小学校で最後の稲刈り': true,
            },
            // Enables debugging statements, to find and help remedy bugs when they occur.
            // This works as a "fail-early" measure and will not show any normal results when an issue is found.
            debugging: false,
        },
        // Used for modifying the current WK Item Info Injector listener
        wkItemInfoHandler: null,
        // Used for working with the settings dialog and determining which sentences to show
        content: {selections: new Set()},
        // Current vocab from wkItemInfo
        item: null,
        // Cached to aid in determining whether retries should be done
        currentUrl: null,
        // Cached so that sentences can be re-rendered after settings change
        immersionKitDataCache: {},
        // Cache for the number of fetches done for any given url
        fetchCount: {},
        // Referenced for quick access to the base node
        baseEl: null,
        // Referenced so that sentences can be re-rendered after settings change
        sentencesEl: null,
        // Reference for quick access to the style sheet
        styleSheetEl: null,
    };
    const exampleLimitSearchRegex = new RegExp(`(#${scriptId} \\.example:nth-child)(\\(n\\+\\d+\\))?`); // /(#media-context-sentences \.example:nth-child)(\(n\+\d+\))?/
    const maxHeightSearchRegex = new RegExp(`(#${scriptId}\\s*{[^}]*?max-height:).*?;`); // /(#media-context-sentences\s*{[^}]*?max-height:) *[\d.] *+\w*;/
    const validCssUnitRegex = /^((\d*\.)?\d+)((px)|(em)|(%)|(ex)|(ch)|(rem)|(vw)|(vh)|(vmin)|(vmax)|(cm)|(mm)|(in)|(pt)|(pc))$/i;

    init();

    function init() {
        if (wkof) {
            wkof.include("Apiv2,Settings,Menu");
            document.documentElement.addEventListener('turbo:load', () => setTimeout(() => wkof.ready('Menu').then(installMenu), 0));
            wkof.ready("Settings")
                .then(createContentListsForSettings)
                .then(migrateOldSettingsLocation)
                .then(loadSettings)
                .then(migrateOldSettings)
                .then(mergeSettings)
                .then(async () => await Promise.all([wkof.ready("Apiv2"), createStyle(), updateDesiredShows()]))
                .then(setWaniKaniItemInfoListener);
        } 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`);
            Promise.all([createStyle(), updateDesiredShows()])
                   .then(setWaniKaniItemInfoListener);
        }
    }

    function setWaniKaniItemInfoListener() {
        if (state.wkItemInfoHandler)
            state.wkItemInfoHandler.remove();
        state.wkItemInfoHandler = wkItemInfo.forType(`${state.settings.showOnKanji ? 'kanji,' : ''}vocabulary,kanaVocabulary`).under(`examples`).notify(onExamplesVisible);
    }

    // ---------------------------------------------------------------------------------------------------------------- //
    // -----------------------------------------------MAIN FUNCTIONALITY----------------------------------------------- //
    // ---------------------------------------------------------------------------------------------------------------- //

    async function addContextSentences() {
        state.baseEl = document.createElement("div");
        state.baseEl.setAttribute("id", `${scriptId}-container`);
        state.sentencesEl = document.createElement("div");
        state.sentencesEl.setAttribute("id", `${scriptId}`);
        state.sentencesEl.innerText = 'Loading...';

        const titleEl = document.createElement("span");
        const header = [], additionalSettings = {sectionName: scriptName, under: 'examples'};
        titleEl.textContent = scriptName;
        header.push(titleEl);

        if (wkof) {
            const settingsBtn = document.createElement("span");
            settingsBtn.textContent = '⚙️';
            settingsBtn.classList.add(`${scriptId}-settings-btn`);
            settingsBtn.onclick = openSettings;
            header.push(settingsBtn);
        }

        state.baseEl.append(state.sentencesEl);

        if (state.item.injector)
            state.item.injector.appendSubsection(header, state.baseEl, additionalSettings);

        state.currentUrl = getNewImmersionKitUrl(state.settings.filterExactSearch);
        const data = await fetchImmersionKitData();
        await renderSentences(data);
    }

    function getNewImmersionKitUrl(exact) {
        let keyword = state.item.characters.replace('〜', ''); // for "counter" kanji
        if (exact) keyword = `「${keyword}」`;
        // TODO: Add &tags=
        const jlptFilter = state.settings.filterJLPTLevel !== 0 ? `&jlpt=${state.settings.filterJLPTLevel}` : '',
            wkLevelFilter = state.settings.filterWaniKaniLevel ? `&wk=${wkof.user.level}` : '', tags = '';
        return `https://api.immersionkit.com/look_up_dictionary?keyword=${keyword}${tags}${jlptFilter}${wkLevelFilter}`;
    }

    async function fetchImmersionKitData() {
        const url1 = state.currentUrl ??= getNewImmersionKitUrl(state.settings.filterExactSearch);
        const url2 = getNewImmersionKitUrl(!state.settings.filterExactSearch);
        let url = url1;

        try {
            for (;;) {
                state.fetchCount[url] ??= 0;
                if (state.immersionKitDataCache[url1] != null)
                    return state.immersionKitDataCache[url1];
                else if (state.item.type === 'kanji' && state.fetchCount[url1] > 0 && state.immersionKitDataCache[url2] != null) {
                    return state.immersionKitDataCache[url2];
                }
                state.fetchCount[url]++;
                state.sentencesEl.innerText = 'Fetching...';
                const response = await fetch(url),
                    json = await response.json(),
                    data = json.data[0];
                if (data.examples.length > 0)
                    return state.immersionKitDataCache[url] = data;
                else if (state.item.type === 'kanji' && !state.fetchCount[url2]) {
                    url = url2;
                    continue;
                } else if (state.fetchCount[url] > state.settings.fetchRetryCount)
                    return data;
                else
                    url = url1;
                const seconds = Math.round(state.settings.fetchRetryDelay / 100) / 10; // round to nearest first decimal
                state.sentencesEl.innerText = `Retrying in ${seconds} second${seconds !== 1 ? 's' : ''}`;
                await sleep(state.settings.fetchRetryDelay);
            }
        } catch (e) {
            console.error(`Error fetching Immersion Kit data: ${e.message}`);
        }
        return null;
    }

    async function onExamplesVisible(item) {
        state.item = item; // current vocab item
        try {
            await addContextSentences(item);
        } catch (e) {
            console.error(`Error while adding ${scriptName} section: ${e.message}`);
        }
    }

    function sortSentences(sentences, primarySorting, secondarySorting) {
        const categoryCompare = (a, b) => a.category.localeCompare(b.category);
        const sourceCompare = (a, b) => a.deck_name.localeCompare(b.deck_name);
        const shortnessCompare = (a, b) => a.sentence.length - b.sentence.length;
        const longnessCompare = (a, b) => b.sentence.length - a.sentence.length;
        const positionCompare = (a, b) => a.furiganaObject.getFirstKeywordIndex() - b.furiganaObject.getFirstKeywordIndex();
        switch (primarySorting) {
            case 'category':
                sentences.sort((a, b) => {
                    const primaryOrder = categoryCompare(a, b);
                    if (primaryOrder !== 0) return primaryOrder;
                    switch (secondarySorting) {
                        case 'source':
                            return sourceCompare(a, b);
                        case 'shortness':
                            return shortnessCompare(a, b);
                        case 'longness':
                            return longnessCompare(a, b);
                        case 'position':
                            return positionCompare(a, b);
                        case 'default':
                        default:
                            return primaryOrder;
                    }
                });
                break;
            case 'longness':
                sentences.sort((a, b) => {
                    const primaryOrder = longnessCompare(a, b);
                    if (primaryOrder !== 0) return primaryOrder;
                    switch (secondarySorting) {
                        case 'category':
                            return categoryCompare(a, b);
                        case 'source':
                            return sourceCompare(a, b);
                        case 'position':
                            return positionCompare(a, b);
                        case 'default':
                        default:
                            return primaryOrder;
                    }
                });
                break;
            case 'shortness':
                sentences.sort((a, b) => {
                    const primaryOrder = shortnessCompare(a, b);
                    if (primaryOrder !== 0) return primaryOrder;
                    switch (secondarySorting) {
                        case 'category':
                            return categoryCompare(a, b);
                        case 'source':
                            return sourceCompare(a, b);
                        case 'position':
                            return positionCompare(a, b);
                        case 'default':
                        default:
                            return primaryOrder;
                    }
                });
                break;
            case 'source':
                sentences.sort((a, b) => {
                    const primaryOrder = sourceCompare(a, b);
                    if (primaryOrder !== 0) return primaryOrder;
                    switch (secondarySorting) {
                        case 'shortness':
                            return shortnessCompare(a, b);
                        case 'longness':
                            return longnessCompare(a, b);
                        case 'position':
                            return positionCompare(a, b);
                        case 'default':
                        default:
                            return primaryOrder;
                    }
                });
                break;
            case 'position':
                sentences.sort((a, b) => {
                    const primaryOrder = positionCompare(a, b);
                    if (primaryOrder !== 0) return primaryOrder;
                    switch (secondarySorting) {
                        case 'category':
                            return categoryCompare(a, b);
                        case 'shortness':
                            return shortnessCompare(a, b);
                        case 'longness':
                            return longnessCompare(a, b);
                        case 'source':
                            return sourceCompare(a, b);
                        case 'default':
                        default:
                            return primaryOrder;
                    }
                });
                break;
            case 'default':
            default:
                break;
        }
    }

    async function renderSentences(data) {
        // Called from immersionkit response, and on settings save
        if (data === null)
            return state.sentencesEl.innerText = 'Error fetching examples from Immersion Kit.';
        if (data.examples.length === 0)
            return state.sentencesEl.innerText = `${state.settings.fetchRetryCount > 0 ? "Retry limit reached. " : ''}No sentences found.`;
        state.sentencesEl.innerText = 'Loading...';
        if (state.settings.debugging)
            state.debugList = new Set();
        const sentencesToDisplay = [];
        // Exclude non-selected titles
        for (let i = 0; i < data.examples.length; i++) {
            const example = data.examples[i];
            if (!(state.content.selections.has(example.deck_name) || state.content.selections.has(example.deck_name_japanese))) {
                if (state.settings.debugging)
                    state.debugList.add(`English: "${example.deck_name}"; Japanese: "${example.deck_name_japanese}"`);
                continue;
            }
            // strip directional formatting and other non-displaying characters from sentences (...how they got there in the first place, I won't ask)
            const directionalFormattingCharsRegex = /[\u202A-\u202E\u2066-\u2069\uE4C6]/g;
            example.sentence = example.sentence.replace(directionalFormattingCharsRegex,'');
            example.sentence_with_furigana = example.sentence_with_furigana.replace(directionalFormattingCharsRegex,'');
            example.furiganaObject = new Furigana(example.sentence, example.sentence_with_furigana);
            const itemKeyword = state.item.characters.replace('〜', '');
            if (state.settings.filterExactMatch && !example.sentence.includes(itemKeyword)) {
                //if (state.settings.debugging) state.debugList.add(`English: "${example.deck_name}"; Japanese: "${example.deck_name_japanese}"; ExactMatch: false`);
                continue;
            }

            const keywordSet = new Set(); // use a set to prevent duplicates from being added.
            if (!state.settings.filterExactMatch && example.word_index.length > 0)
                for (let j = 0; j < example.word_index.length; j++)
                    keywordSet.add(example.word_list[example.word_index[j]]);
            const sentenceKeywords = Array.from(keywordSet);
            const regexExpression = (sentenceKeywords.length === 0
                // default to the keyword from the item if word_list is empty
                ? itemKeyword.split('').join('\\s*') // intersperse whitespace quantifier to match awkwardly spaced out sentences.
                // use the keywords from the sentence data if they exist properly.
                : sentenceKeywords.join('|')); // use alternation when using the example's word_list (which will end up creating tags around each "word").
            example.furiganaObject.setKeyword(regexExpression);

            sentencesToDisplay.push(example);
        }
        if (sentencesToDisplay.length === 0 || state.settings.debugging && state.debugList.size > 0) {
            const preElement = document.createElement("pre");
            const deckCountsAsJson = JSON.stringify(data.deck_count, undefined, "\t");
            preElement.innerHTML = `${sentencesToDisplay.length>0 ? sentencesToDisplay.length : 'No'} sentences found for your selected filters (${data.examples.length-sentencesToDisplay.length} are available but hidden; see below for details and entry counts)<br>${deckCountsAsJson}`;
            if (state.settings.debugging)
                preElement.innerHTML += `<br><br>Names for decks currently hidden:<br>${Array.from(state.debugList).join('<br>')}`;
            state.sentencesEl.replaceChildren(preElement);
            return;
        }

        const fragment = document.createDocumentFragment();
        sortSentences(sentencesToDisplay, state.settings.sentenceSorting, state.settings.sentenceSortingSecondary);
        for (let i = 0; i < sentencesToDisplay.length; i++) {
            const example = sentencesToDisplay[i];
            const exampleElement = await createExampleElement(example);
            fragment.appendChild(exampleElement);
        }
        state.sentencesEl.replaceChildren(fragment);
    }

    async function createExampleElement(example) {
        const parentEl = document.createElement("div");
        parentEl.className = 'example';

        const imgEl = document.createElement("img");
        imgEl.src = example.image_url ?? "";
        imgEl.decoding = "auto";
        imgEl.alt = '';

        const textParentEl = document.createElement("div");
        textParentEl.className = 'example-text';
        attachAudioOnClickListener(parentEl);

        const textTitleEl = document.createElement("div");
        textTitleEl.className = 'title';
        textTitleEl.title = example.id; // TODO: Consider removing/moving elsewhere
        textTitleEl.innerText = example.deck_name;

        const audioButtonEl = document.createElement("button");
        audioButtonEl.type = 'button';
        audioButtonEl.className = 'audio-btn audio-idle';
        audioButtonEl.title = 'Play Audio';
        audioButtonEl.innerText = '🔈';
        configureAudioElement(audioButtonEl, example);

        const jaEl = document.createElement("div");
        jaEl.className = 'ja';

        const jaSpanEl = document.createElement("span");
        jaSpanEl.classList.add('base');
        jaSpanEl.innerHTML = example.furiganaObject.getExpressionHtml();

        const jaFuriganaSpanEl = document.createElement("span");
        jaFuriganaSpanEl.classList.add('furigana');
        jaFuriganaSpanEl.innerHTML = example.furiganaObject.getFuriganaHtml();

        const enEl = document.createElement("div");
        enEl.className = 'en';

        const enSpanEl = document.createElement("span");
        enSpanEl.innerHTML = example.translation;

        const elements = [
            {element: jaSpanEl,
                classListUpdates: [{name: 'showJapanese', value: state.settings.showJapanese}, {name: 'showFurigana', value: state.settings.showFurigana}],
                clickListener: {name: 'showJapanese', value: state.settings.showJapanese}},
            {element: jaFuriganaSpanEl,
                classListUpdates: [{name: 'showJapanese', value: state.settings.showJapanese}, {name: 'showFurigana', value: state.settings.showFurigana}],
                clickListener: {name: 'showFurigana', value: state.settings.showFurigana}},
            {element: enSpanEl,
                classListUpdates: [{name: 'showEnglish', value: state.settings.showEnglish}],
                clickListener: {name: 'showEnglish', value: state.settings.showEnglish}},
        ];
        const promises = [];
        for (const {element, classListUpdates, clickListener} of elements) {
            for (const {name, value} of classListUpdates)
                promises.push(updateClassListForSpanElement(element, name, value));
            promises.push(updateOnClickListenerForSpanElement(jaSpanEl, clickListener.name, clickListener.value));
        }
        await Promise.all(promises);

        parentEl.append(imgEl);
        textTitleEl.append(audioButtonEl);
        textParentEl.append(textTitleEl);

        jaEl.append(jaSpanEl);
        jaEl.append(jaFuriganaSpanEl);
        enEl.append(enSpanEl);
        textParentEl.append(jaEl);
        textParentEl.append(enEl);

        parentEl.append(textParentEl);
        return parentEl;
    }

    // ---------------------------------------------------------------------------------------------------------------- //
    // ----------------------------------------------------HELPERS----------------------------------------------------- //
    // ---------------------------------------------------------------------------------------------------------------- //

    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    function isEmptyObject(value) {
        if (value == null) {
            // null or undefined
            return false;
        }

        if (typeof value !== 'object') {
            // boolean, number, string, function, etc.
            return false;
        }

        const proto = Object.getPrototypeOf(value);

        // consider `Object.create(null)`, commonly used as a safe map
        // before `Map` support, an empty object as well as `{}`
        if (proto !== null && proto !== Object.prototype) {
            return false;
        }

        for (const prop in value) {
            if (Object.hasOwn(value, prop)) {
                return false;
            }
        }

        return true;
    }

    function arrayValuesEqual(a, b) {
        if (a === b) return true;
        if (a == null || b == null) return false;
        let aValues = Object.values(a), bValues = Object.values(b);
        if (aValues.length !== bValues.length) return false;
        for (let i = 0; i < aValues.length; ++i) {
            if (aValues[i] !== bValues[i]) return false;
        }
        return true;
    }

    function updateObjectValuesToValuesFromOtherObject(object, otherObject) {
        const keys = Object.keys(object);
        const values = Array.isArray(otherObject) ? otherObject : Object.values(otherObject);
        for (let i = 0; i < keys.length && i < values.length; i++) {
            object[keys[i]] = values[i];
        }
        return object;
    }

    function combineObjectsWithTrueValuesToSet(...objects) {
        const set = new Set();
        for (let i = 0; i < objects.length; i++){
            const entries = Object.entries(objects[i]);
            for (let j = 0; j < entries.length; j++){
                const [key, value] = entries[j];
                if (value)
                    set.add(key);
            }
        }
        return set;
    }

    async function setObjectEntriesEqualToOtherObjectKeys(outputObject, object) {
        const keys = Object.keys(object);
        for (let i = 0; i < keys.length; i++) {
            const key = keys[i];
            outputObject[key] = key;
        }
    }

    // ----------------------------------------------ELEMENT MANIPULATION---------------------------------------------- //

    function configureAudioElement(element, example) {
        let audioContainer;
        const idleClassName = "audio-idle";
        const playingClassName = "audio-play";
        const onPlay = () => {
            element.classList.replace(idleClassName, playingClassName);
            element.textContent = '🔊';
        }, onStop = () => {
            element.classList.replace(playingClassName, idleClassName);
            element.textContent = '🔈';
            removeAudioElement(audioContainer);
        };
        element.onclick = function(e) {
            e.stopPropagation(); // prevent this click from triggering twice in some scenarios
            if ((audioContainer = state.baseEl.querySelector("audio")) !== null) {
                const prevSource = audioContainer.src;
                audioContainer.pause();
                if (prevSource === example.sound_url)
                    return;
            }
            audioContainer = document.createElement("audio");
            audioContainer.src = example.sound_url;
            audioContainer.playbackRate = state.settings.playbackRate * 2 / 100;
            audioContainer.volume = state.settings.playbackVolume / 100;
            audioContainer.onplay = onPlay;
            audioContainer.onpause = onStop;
            audioContainer.onended = onStop;
            audioContainer.onabort = onStop;
            state.baseEl.append(audioContainer);
            audioContainer.play();
        };
    }

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

    async function updateClassListForSpanElement(element, name, value) {
        switch (name) {
            case 'showEnglish':
            case 'showJapanese':
                element.classList.toggle('show-on-click', value === 'onclick');
                element.classList.toggle('show-on-hover', value === 'onhover');
                break;
            case 'showFurigana':
                if (element.classList.contains('base'))
                    element.classList.toggle('hide', value !== 'never');
                else if (element.classList.contains('furigana')) {
                    element.classList.toggle('show-ruby-on-hover', value === 'onhover');
                    element.classList.toggle('hide', value === 'never');
                }
                break;
        }
    }

    // ----------------------------------------------------ON CLICK---------------------------------------------------- //
    async function updateOnClickListenerForSpanElement(element, name, value) {
        switch (value) {
            case 'always':
            case 'onhover':
            case 'never':
                if (name !== 'showFurigana') await removeOnClickEventListener(element);
                break;
            case 'onclick':
                await attachShowOnClickEventListener(element);
                break;
            default:
                return;
        }
    }
    function attachAudioOnClickListener(element) {
        // Click anywhere plays the audio
        element.onclick = function () {
            if (this.classList.contains('show-on-click')) return;
            const button = this.querySelector('.audio-btn');
            button.click();
        };
    }
    async function attachShowOnClickEventListener(element) {
        // Assign onclick function to toggle the .show-on-click class
        element.onclick = e => {
            e.stopPropagation(); // prevent this click from triggering the audio to play
            element.classList.toggle('show-on-click');
        };
    }

    async function removeOnClickEventListener(element) {
        element.onclick = null;
    }

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

    async function createContentListsForSettings() {
        // Create the content lists to be used by the WKOF settings dialog.
        await Promise.all([
            setObjectEntriesEqualToOtherObjectKeys(state.content.animeShows = {}, state.settings.filterAnimeShows),
            setObjectEntriesEqualToOtherObjectKeys(state.content.animeMovies = {}, state.settings.filterAnimeMovies),
            setObjectEntriesEqualToOtherObjectKeys(state.content.ghibli = {}, state.settings.filterGhibli),
            setObjectEntriesEqualToOtherObjectKeys(state.content.dramas = {}, state.settings.filterDramas),
            setObjectEntriesEqualToOtherObjectKeys(state.content.games = {}, state.settings.filterGames),
            setObjectEntriesEqualToOtherObjectKeys(state.content.literature = {}, state.settings.filterLiterature),
            setObjectEntriesEqualToOtherObjectKeys(state.content.news = {}, state.settings.filterNews),
        ]);
    }

    /** Installs the options button in the menu */
    function installMenu() {
        const config = {
            name: scriptId,
            submenu: 'Settings',
            title: scriptName,
            on_click: openSettings,
        };
        wkof.Menu.insert_script_link(config);
    }

    async function loadSettings() {
        try {
            return await wkof.Settings.load(scriptId, state.settings);
        } catch (e) {
            console.error(`Error loading settings from WaniKani Open Framework: ${e.message}`);
        }
    }

    function mergeSettings(settings) {
        // need to use Object.assign() in order to avoid updating the state.settings object byref whenever it's saved
        Object.assign(state.settings, settings);
    }

    async function migrateOldSettings(settings) {
        let changed;
        // update legacy maxBoxHeight from a simple number to a text value to allow specifying the exact measurement unit (e.g., if something other than "px" is desired)
        if (!Number.isNaN(Number(settings.maxBoxHeight))) changed = wkof.settings[scriptId].maxBoxHeight = `${settings.maxBoxHeight}px`;
        // update legacy sentenceSorting values from "none" to "source"
        if (settings.sentenceSorting === 'none') changed = wkof.settings[scriptId].sentenceSorting = 'source';
        // update legacy playbackRate settings from a decimal value to a raw percentage out of 200
        if (settings.playbackRate <= 2) changed = wkof.settings[scriptId].playbackRate = settings.playbackRate * 50;
        // update legacy filters from simple arrays or pseudo-array objects into objects with the key being the source title
        if (settings.filterAnimeShows[0] !== undefined) changed = wkof.settings[scriptId].filterAnimeShows = updateObjectValuesToValuesFromOtherObject(state.settings.filterAnimeShows, settings.filterAnimeShows);
        if (settings.filterAnimeMovies[0] !== undefined) changed = wkof.settings[scriptId].filterAnimeMovies = updateObjectValuesToValuesFromOtherObject(state.settings.filterAnimeMovies, settings.filterAnimeMovies);
        if (settings.filterGhibli[0] !== undefined) changed = wkof.settings[scriptId].filterGhibli = updateObjectValuesToValuesFromOtherObject(state.settings.filterGhibli, settings.filterGhibli);
        if (settings.filterDramas[0] !== undefined) changed = wkof.settings[scriptId].filterDramas = updateObjectValuesToValuesFromOtherObject(state.settings.filterDramas, settings.filterDramas);
        if (settings.filterGames[0] !== undefined) changed = wkof.settings[scriptId].filterGames = updateObjectValuesToValuesFromOtherObject(state.settings.filterGames, settings.filterGames);
        if (settings.filterLiterature[0] !== undefined) changed = wkof.settings[scriptId].filterLiterature = updateObjectValuesToValuesFromOtherObject(state.settings.filterLiterature, settings.filterLiterature);
        if (settings.filterNews[0] !== undefined) changed = wkof.settings[scriptId].filterNews = updateObjectValuesToValuesFromOtherObject(state.settings.filterNews, settings.filterNews);
        if (changed !== undefined) {
            try {
                await wkof.Settings.save(scriptId);
            } catch (e) {
                console.error(`Error migrating old settings from WaniKani Open Framework: ${e.message}`);
            }
        }
        return settings;
    }

    async function migrateOldSettingsLocation() {
        try {
            const oldSettings = await wkof.Settings.load(oldScriptId);
            delete wkof.settings[oldScriptId];
            if (!oldSettings || isEmptyObject(oldSettings))
                return;
            wkof.settings[scriptId] = oldSettings;
            await wkof.Settings.save(scriptId);
            await wkof.file_cache.delete(`wkof.settings.${oldScriptId}`);
        } catch (e) {
            console.error(`Error loading old settings from WaniKani Open Framework: ${e.message}`);
        }
    }

    async function onSettingsSaved(updatedSettings) {
        // Called when the user clicks the Save button on the Settings dialog.
        let shouldRerender = false;
        const {
            exampleLimit, // changes handled in onSettingsClosed
            fetchRetryCount, // changes handled in onSettingsClosed
            fetchRetryDelay, // changes handled in onSettingsClosed
            filterAnimeMovies,
            filterAnimeShows,
            filterDramas,
            filterExactMatch,
            filterExactSearch,
            filterGames,
            filterGhibli,
            filterJLPTLevel,
            filterLiterature,
            filterNews,
            filterWaniKaniLevel,
            maxBoxHeight, // changes handled in onSettingsClosed
            playbackRate, // changes handled in onSettingsClosed
            playbackVolume, // changes handled in onSettingsClosed
            sentenceSorting,
            sentenceSortingSecondary,
            showEnglish, // changes handled in onSettingsClosed
            showFurigana, // changes handled in onSettingsClosed
            showJapanese, // changes handled in onSettingsClosed
            showOnKanji, // changes handled in onSettingsClosed
            debugging, // Not implemented in settings
            highlighting // Not implemented
        } = state.settings;
        const {
            exampleLimit: exampleLimitNew, // changes handled in onSettingsClosed
            fetchRetryCount: fetchRetryCountNew, // changes handled in onSettingsClosed
            fetchRetryDelay: fetchRetryDelayNew, // changes handled in onSettingsClosed
            filterAnimeMovies: filterAnimeMoviesNew,
            filterAnimeShows: filterAnimeShowsNew,
            filterDramas: filterDramasNew,
            filterExactMatch: filterExactMatchNew,
            filterExactSearch: filterExactSearchNew,
            filterGames: filterGamesNew,
            filterGhibli: filterGhibliNew,
            filterJLPTLevel: filterJLPTLevelNew,
            filterLiterature: filterLiteratureNew,
            filterNews: filterNewsNew,
            filterWaniKaniLevel: filterWaniKaniLevelNew,
            maxBoxHeight: maxBoxHeightNew, // changes handled in onSettingsClosed
            playbackRate: playbackRateNew, // changes handled in onSettingsClosed
            playbackVolume: playbackVolumeNew, // changes handled in onSettingsClosed
            sentenceSorting: sentenceSortingNew,
            sentenceSortingSecondary: sentenceSortingSecondaryNew,
            showEnglish: showEnglishNew, // changes handled in onSettingsClosed
            showFurigana: showFuriganaNew, // changes handled in onSettingsClosed
            showJapanese: showJapaneseNew, // changes handled in onSettingsClosed
            showOnKanji: showOnKanjiNew, // changes handled in onSettingsClosed
            debugging: debuggingNew, // Not implemented in settings
            highlighting: highlightingNew, // Not implemented
        } = updatedSettings;

        const animeShowsListsDiffer = !arrayValuesEqual(filterAnimeShows,filterAnimeShowsNew),
            animeMoviesListsDiffer = !arrayValuesEqual(filterAnimeMovies,filterAnimeMoviesNew),
            ghibliListsDiffer = !arrayValuesEqual(filterGhibli,filterGhibliNew),
            dramasListsDiffer = !arrayValuesEqual(filterDramas,filterDramasNew),
            gamesListsDiffer = !arrayValuesEqual(filterGames,filterGamesNew),
            literatureListsDiffer = !arrayValuesEqual(filterLiterature,filterLiteratureNew),
            newsListsDiffer = !arrayValuesEqual(filterNews,filterNewsNew);
        // avoid many issues by updating the values manually exactly as desired
        // state.settings.exampleLimit = exampleLimitNew;
        // state.settings.fetchRetryCount = fetchRetryCountNew;
        // state.settings.fetchRetryDelay = fetchRetryDelayNew;
        state.settings.filterExactMatch = filterExactMatchNew;
        state.settings.filterExactSearch = filterExactSearchNew;
        state.settings.filterJLPTLevel = filterJLPTLevelNew;
        state.settings.filterWaniKaniLevel = filterWaniKaniLevelNew;
        // state.settings.maxBoxHeight = !Number.isNaN(Number(maxBoxHeightNew)) ? `${maxBoxHeightNew}px` : maxBoxHeightNew;
        // state.settings.playbackRate = playbackRateNew;
        // state.settings.playbackVolume = playbackVolumeNew;
        state.settings.sentenceSorting = sentenceSortingNew;
        state.settings.sentenceSortingSecondary = sentenceSortingSecondaryNew;
        // state.settings.showEnglish = showEnglishNew;
        // state.settings.showFurigana = showFuriganaNew;
        // state.settings.showJapanese = showJapaneseNew;
        // state.settings.showOnKanji = showOnKanjiNew;
        state.settings.debugging = debuggingNew;
        state.settings.highlighting = highlightingNew;

        // if (showOnKanji !== showOnKanjiNew) setWaniKaniItemInfoListener(showOnKanjiNew);
        if (filterExactSearchNew !== filterExactSearch || filterJLPTLevel !== filterJLPTLevelNew || filterWaniKaniLevel !== filterWaniKaniLevelNew) {
            state.currentUrl = getNewImmersionKitUrl(filterExactSearchNew);
            shouldRerender = true;
        }
        if (animeShowsListsDiffer || animeMoviesListsDiffer || ghibliListsDiffer || dramasListsDiffer || gamesListsDiffer || literatureListsDiffer || newsListsDiffer) {
            state.settings.filterAnimeShows = animeShowsListsDiffer ? filterAnimeShowsNew : filterAnimeShows;
            state.settings.filterAnimeMovies = animeMoviesListsDiffer ? filterAnimeMoviesNew : filterAnimeMovies;
            state.settings.filterGhibli = ghibliListsDiffer ? filterGhibliNew : filterGhibli;
            state.settings.filterDramas = dramasListsDiffer ? filterDramasNew : filterDramas;
            state.settings.filterGames = gamesListsDiffer ? filterGamesNew : filterGames;
            state.settings.filterLiterature = literatureListsDiffer ? filterLiteratureNew : filterLiterature;
            state.settings.filterNews = newsListsDiffer ? filterNewsNew : filterNews;
            await updateDesiredShows();
            shouldRerender = true;
        } else if (filterExactMatch !== filterExactMatchNew || sentenceSorting !== sentenceSortingNew || sentenceSortingSecondary !== sentenceSortingSecondaryNew) {
            shouldRerender = true;
        }
        if (!shouldRerender)
            return;
        const data = await fetchImmersionKitData();
        await renderSentences(data);
    }

    function openSettings(e) {
        e.stopPropagation();
        const showTextOptions = {always: "Always", onhover: "On Hover", onclick: "On Click"},
            showFuriganaOptions = {always: "Always", onhover: "On Hover", never: "Never"},
            jlptOptions = {0: "No Filter", 1: "N1", 2: "N2", 3: "N3", 4: "N4", 5: "N5"},
            sortingMethods = {
                default: "Default",
                category: "Category (anime, drama, etc.)",
                source: "Source Title",
                shortness: "Shortest sentences first",
                longness: "Longest sentences first",
                position: "Position of keyword in sentence",
            };

        function getMissingSortingMethods(currentSecondarySortingOptions) {
            return Object.entries(sortingMethods).filter(([key]) => currentSecondarySortingOptions[key] === undefined);
        }
        function onPrimarySortOptionChanged(name, value) {
            // TODO: This method is a somewhat cursed way of handling this and should be replaced by a natively available method via WKOF if/when I can figure one out.
            const menu = document.getElementById(`${scriptId}_sentenceSortingSecondary`);
            if (menu === null) return;
            const options = menu.options,
                allKeys = Object.keys(sortingMethods),
                missingSortingMethods = getMissingSortingMethods(options);
            for (let i = 0; i < missingSortingMethods.length; i++) {
                const [missingName, missingValue] = missingSortingMethods[i],
                    insertBefore = allKeys.indexOf(missingName),
                    newOption = document.createElement("option");
                newOption.setAttribute('name', missingName);
                newOption.text = missingValue;
                options.add(newOption, insertBefore);
            }
            switch (value) {
                case 'category':
                case 'position':
                    break;
                case 'longness':
                    options.remove(options.namedItem('shortness').index);
                    break;
                case 'shortness':
                    options.remove(options.namedItem('longness').index);
                    break;
                case 'source':
                    options.remove(options.namedItem('category').index);
                    break;
                case 'default':
                default:
                    options.remove(options.namedItem('category').index);
                    options.remove(options.namedItem('source').index);
                    options.remove(options.namedItem('longness').index);
                    options.remove(options.namedItem('shortness').index);
                    options.remove(options.namedItem('position').index);
                    return;
            }
            options.remove(options.namedItem(value).index);
        }
        function getSecondarySortingMethods(primarySorting) {
            const sortingMethodsCopy = Object.assign({}, sortingMethods);
            switch (primarySorting) {
                case 'category':
                    delete sortingMethodsCopy.category;
                    return sortingMethodsCopy;
                case 'longness':
                    delete sortingMethodsCopy.shortness;
                    delete sortingMethodsCopy.longness;
                    return sortingMethodsCopy;
                case 'shortness':
                    delete sortingMethodsCopy.shortness;
                    delete sortingMethodsCopy.longness;
                    return sortingMethodsCopy;
                case 'source':
                    delete sortingMethodsCopy.category;
                    delete sortingMethodsCopy.source;
                    return sortingMethodsCopy;
                case 'position':
                    delete sortingMethodsCopy.position;
                    return sortingMethodsCopy;
                case 'default':
                default:
                    delete sortingMethodsCopy.category;
                    delete sortingMethodsCopy.source;
                    delete sortingMethodsCopy.shortness;
                    delete sortingMethodsCopy.longness;
                    delete sortingMethodsCopy.position;
                    return sortingMethodsCopy;
            }
        }
        const settingsConfig = {
            script_id: scriptId, title: scriptName, on_save: onSettingsSaved, on_close: onSettingsClosed,
            content: {
                general: {
                    type: "page", label: "General", content: {
                        generalDescription: {
                            type: 'section', label: 'Changes to settings in this tab can be previewed in real-time.'
                        }, appearanceOptions: {
                            type: "group", label: "Appearance Options", content: {
                                showOnKanji: {
                                    type: "checkbox", label: "Show on Kanji Items", default: state.settings.showOnKanji,
                                    hover_tip: "Allows the box to appear in the Examples tab for kanji in addition to vocabulary.",
                                    on_change: onShowOnKanjiOptionChanged
                                }, maxBoxHeight: {
                                    type: "text", label: "Box Height", step: 1, min: 0, default: state.settings.maxBoxHeight,
                                    hover_tip: "Set the maximum height of the container box.\nIf no unit type is provided, px (pixels) is automatically appended.",
                                    on_change: onMaxBoxHeightOptionChanged, validate: validateMaxHeight
                                }, exampleLimit: {
                                    type: "number", label: "Example Limit", step: 1, min: 0, default: state.settings.exampleLimit,
                                    hover_tip: "Limit the number of entries that may appear.\nSet to 0 to show as many as possible (note that this can really lag the list generation when there are a very large number of matches).",
                                    on_change: onExampleLimitOptionChanged
                                }, showJapanese: {
                                    type: "dropdown", label: "Show Japanese", default: state.settings.showJapanese, content: showTextOptions,
                                    hover_tip: "When to show Japanese text.\nHover enables transcribing a sentences first (play audio by clicking the image to avoid seeing the answer).",
                                    on_change: onTextShowOptionChanged
                                }, showFurigana: {
                                    type: "dropdown", label: "Show Furigana", default: state.settings.showFurigana, content: showFuriganaOptions,
                                    hover_tip: "These have been autogenerated so there may be mistakes.",
                                    on_change: onTextShowOptionChanged
                                }, showEnglish: {
                                    type: "dropdown", label: "Show English", default: state.settings.showEnglish, content: showTextOptions,
                                    hover_tip: "Hover or click allows testing your understanding before seeing the answer.",
                                    on_change: onTextShowOptionChanged
                                }
                            }
                        }, playbackOptions: {
                            type: "group", label: "Playback Options", content: {
                                playbackRate: {
                                    type: "input", subtype: "range", label: "Playback Speed", default: state.settings.playbackRate,
                                    hover_tip: "Speed to play back audio. (10% - 200%)",
                                    on_change: onAudioPlaybackOptionChanged, validate: validatePlaybackRate
                                }, playbackVolume: {
                                    type: "input", subtype: "range", label: "Playback Volume", default: state.settings.playbackVolume,
                                    hover_tip: "Volume to play back audio. (0% - 100%)",
                                    on_change: onAudioPlaybackOptionChanged, validate: validatePlaybackVolume
                                }

                            }
                        }, immersionKitDataFetchingOptions: {
                            type: "group", label: "Immersion Kit Data Fetching Options", content: {
                                fetchRetryCount: {
                                    type: "number", label: "Fetch Retry Count", step: 1, min: 0, default: state.settings.fetchRetryCount,
                                    hover_tip: "Set how many times you would like to allow retrying the fetch for sentences (to workaround backend issues).",
                                    on_change: onFetchOptionChanged
                                }, fetchRetryDelay: {
                                    type: "number", label: "Fetch Retry Delay (ms)", step: 1, min: 0, default: state.settings.fetchRetryDelay,
                                    hover_tip: "Set the delay in milliseconds between each retry attempt.",
                                    on_change: onFetchOptionChanged
                                }
                            }
                        }
                    }
                }, sorting: {
                    type: 'page', label: 'Sorting', content: {
                        sentenceSortOptions: {
                            type: "group", label: "Sentence Sorting Options", content: {
                                sentenceSorting: {
                                    type: "dropdown", label: "Primary Sorting Method", default: state.settings.sentenceSorting, content: sortingMethods,
                                    hover_tip: "Choose in what order the sentences will be presented.\nDefault = Exactly as retrieved from Immersion Kit",
                                    on_change: onPrimarySortOptionChanged
                                }, sentenceSortingSecondary: {
                                    type: "dropdown", label: "Secondary Sorting Method", default: state.settings.sentenceSortingSecondary, content: getSecondarySortingMethods(state.settings.sentenceSorting),
                                    hover_tip: "Choose how you would like to sort equivalencies in the primary sorting method.\nDefault = No secondary sorting"
                                }
                            }
                        }
                    }
                }, filters: {
                    type: "page", label: "Filters", content: {
                        sentenceFilteringOptions: {
                            type: "group", label: "Sentence Filtering Options", content: {
                                filterExactMatch: {
                                    type: "checkbox", label: "Exact Match", default: state.settings.filterExactMatch,
                                    hover_tip: 'Text must match term exactly, i.e., this filters out conjugations/inflections.\nChecking this for a word with kanji means it will not match if the sentence has it only in kana form and vice-versa for kana-only vocabulary.\n\nThis filtering is done after the results are retrieved from Immersion Kit and may yield different results than the "Exact Search" option (below) when the latter is not used.'
                                },
                                filterAnimeShows: {
                                    type: "list", label: "Anime Shows", multi: true, size: 6, default: state.settings.filterAnimeShows, content: state.content.animeShows,
                                    hover_tip: "Select the anime shows that can be included in the examples."
                                }, filterAnimeMovies: {
                                    type: "list", label: "Anime Movies", multi: true, size: 6, default: state.settings.filterAnimeMovies, content: state.content.animeMovies,
                                    hover_tip: "Select the anime movies that can be included in the examples."
                                }, filterGhibli: {
                                    type: "list", label: "Ghibli Movies", multi: true, size: 6, default: state.settings.filterGhibli, content: state.content.ghibli,
                                    hover_tip: "Select the Studio Ghibli movies that can be included in the examples."
                                }, filterDramas: {
                                    type: "list", label: "Dramas", multi: true, size: 6, default: state.settings.filterDramas, content: state.content.dramas,
                                    hover_tip: "Select the dramas that can be included in the examples."
                                }, filterGames: {
                                    type: "list", label: "Games", multi: true, size: 3, default: state.settings.filterGames, content: state.content.games,
                                    hover_tip: "Select the video games that can be included in the examples."
                                }, filterLiterature: {
                                    type: "list", label: "Literature", multi: true, size: 6, default: state.settings.filterLiterature, content: state.content.literature,
                                    hover_tip: "Select the pieces of literature that can be included in the examples."
                                }, filterNews: {
                                    type: "list", label: "News", multi: true, size: 6, default: state.settings.filterNews, content: state.content.news,
                                    hover_tip: "Select the news sources that can be included in the examples."
                                }
                            }
                        }, immersionKitSearchOptions: {
                            type: "group", label: "Immersion Kit Search Options", content: {
                                immersionKitSearchDescription: {
                                    type: 'section', label: "Changes here cause an API request unless already cached."
                                }, filterExactSearch: {
                                    type: "checkbox", label: "Exact Search", default: state.settings.filterExactSearch,
                                    hover_tip: "Text must match term exactly, i.e., this filters out conjugations/inflections.\nChecking this for a word with kanji means it will not match if the sentence has it only in kana form and vice-versa for kana-only vocabulary."
                                }, filterWaniKaniLevel: {
                                    type: "checkbox", label: "WaniKani Level", default: state.settings.filterWaniKaniLevel,
                                    hover_tip: "Only show sentences with maximum 1 word outside of your current WaniKani level.",
                                }, filterJLPTLevel: {
                                    type: "dropdown", label: "JLPT Level", default: state.settings.filterJLPTLevel, content: jlptOptions,
                                    hover_tip: "Only show sentences matching a particular JLPT Level or easier.",
                                }
                            }
                        }
                    }
                }, credits: {
                    type: "html", label: "Powered by", 
                    html: '<a href="https://www.immersionkit.com" style="vertical-align: middle; vertical-align: -webkit-baseline-middle; vertical-align: -moz-middle-with-baseline;">immersionkit.com</a>'
                },
            }
        };
        const dialog = new wkof.Settings(settingsConfig);
        dialog.open();
    }

    async function onAudioPlaybackOptionChanged(name, value) {
        const audioContainer = state.baseEl.querySelector("audio");
        if (audioContainer === null) return;
        switch (name) {
            case "playbackRate":
                if (value === state.settings.playbackRate) return;
                state.settings.playbackRate = value;
                audioContainer.playbackRate = value * 2 / 100;
                break;
            case "playbackVolume":
                if (value === state.settings.playbackVolume) return;
                state.settings.playbackVolume = value;
                audioContainer.volume = value / 100;
                break;
        }
    }

    async function onExampleLimitOptionChanged(name, value) {
        if (value === state.settings.exampleLimit) return;
        // Adjust the example limit with CSS to avoid recreating the list
        const replacement = value===0 ? '$1' : `$1(n+${value+1})`;
        state.settings.exampleLimit = value;
        state.styleSheetEl.innerHTML = state.styleSheetEl.innerHTML.replace(exampleLimitSearchRegex,replacement);
    }

    async function onFetchOptionChanged(name, value) {
        let prevRetryCount;
        switch (name) {
            case "fetchRetryCount":
                // TODO: Possibly make this not affect the fetch count when the dialog was canceled instead of saved
                if (value === state.settings.fetchRetryCount) return;
                prevRetryCount = state.settings.fetchRetryCount;
                state.settings.fetchRetryCount = value;
                if (state.sentencesEl.childElementCount === 0 && value > prevRetryCount && value >= (state.fetchCount[state.currentUrl] ?? 0)) {
                    const data = await fetchImmersionKitData();
                    await renderSentences(data);
                }
                break;
            case "fetchRetryDelay":
                if (value === state.settings.fetchRetryDelay) return;
                state.settings.fetchRetryDelay = value;
                break;
        }
    }

    async function onMaxBoxHeightOptionChanged(name, value) {
        if (value === state.settings.maxBoxHeight) return;
        if (!Number.isNaN(Number(value))) {
            value += "px";
            state.settings.maxBoxHeight = wkof.settings[scriptId].maxBoxHeight = value;
        }
        const replacement = `$1 ${value};`;
        state.styleSheetEl.innerHTML = state.styleSheetEl.innerHTML.replace(maxHeightSearchRegex,replacement);
    }

    async function onShowOnKanjiOptionChanged(name, value) {
        if (value === state.settings.showOnKanji) return;
        state.settings.showOnKanji = value;
        setWaniKaniItemInfoListener();
    }

    async function onSettingsClosed(settings) {
        // Revert any modifications that were unsaved, or finalize any that were.
        await Promise.all([
            onShowOnKanjiOptionChanged('showOnKanji', settings.showOnKanji),
            onAudioPlaybackOptionChanged("playbackRate", settings.playbackRate),
            onAudioPlaybackOptionChanged("playbackVolume", settings.playbackVolume),
            onExampleLimitOptionChanged("exampleLimit", settings.exampleLimit),
            onFetchOptionChanged("fetchRetryCount", settings.fetchRetryCount),
            onFetchOptionChanged("fetchRetryDelay", settings.fetchRetryDelay),
            onMaxBoxHeightOptionChanged("maxBoxHeight", settings.maxBoxHeight),
            onTextShowOptionChanged('showJapanese', settings.showJapanese),
            onTextShowOptionChanged('showFurigana', settings.showFurigana),
            onTextShowOptionChanged('showEnglish', settings.showEnglish),
        ]);
    }

    async function onTextShowOptionChanged(name, value) {
        let selector;
        switch (name) {
            case "showEnglish":
                if (value === state.settings.showEnglish) return;
                state.settings.showEnglish = value;
                selector = '.example-text .en > span';
                break;
            case "showFurigana":
                if (value === state.settings.showFurigana) return;
                state.settings.showFurigana = value;
            // fallthrough
            case "showJapanese":
                if (value === state.settings.showJapanese) return;
                state.settings.showJapanese = value;
                selector = '.example-text .ja > span';
                break;
            default:
                return;
        }
        const exampleEls = state.sentencesEl.querySelectorAll(selector);
        const promises = [];
        for (let i = 0; i < exampleEls.length; i++) {
            const el = exampleEls[i];
            promises.push(updateClassListForSpanElement(el, name, value));
            promises.push(updateOnClickListenerForSpanElement(el, name, value));
        }
        await Promise.all(promises);
    }

    async function updateDesiredShows() {
        // Combine settings objects to a single set containing the desired titles
        state.content.selections = combineObjectsWithTrueValuesToSet(state.settings.filterAnimeShows, state.settings.filterAnimeMovies, state.settings.filterGhibli, state.settings.filterDramas, state.settings.filterGames, state.settings.filterLiterature, state.settings.filterNews);
    }

    function validateMaxHeight(value) {
        return value === undefined || value === null || value === "" || validCssUnitRegex.test(value) || 'Number and (optional) valid unit type only';
    }

    function validatePlaybackRate(value) {
        return {valid: value >= 5, msg: `${value * 2}%`};
    }

    function validatePlaybackVolume(value) {
        return {valid: true, msg: `${value}%`};
    }

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

    async function createStyle() {
        state.styleSheetEl = document.createElement("style");
        state.styleSheetEl.setAttribute("id", styleSheetName);
        state.styleSheetEl.setAttribute("type", "text/css");
        // language=CSS
        state.styleSheetEl.innerHTML = `
#${scriptId} { max-height: ${state.settings.maxBoxHeight}; overflow-y: auto; }
#${scriptId} .example:nth-child${state.settings.exampleLimit===0?'':`(n+${state.settings.exampleLimit+1})`} { display: none; }
.${scriptId}-settings-btn { font-size: 14px; cursor: pointer; vertical-align: middle; margin-left: 10px; }
#${scriptId}-container { border: none; font-size: 100%; }
#${scriptId} pre { white-space: pre-wrap; white-space: -moz-pre-wrap; white-space: -pre-wrap; white-space: -o-pre-wrap; word-wrap: break-word; }
#${scriptId} .example { display: flex; align-items: center; margin-bottom: 1em; cursor: pointer; }
#${scriptId} .example > * { flex-grow: 1; flex-shrink: 1; flex-basis: min-content; }
#${scriptId} .example img { padding-right: 1em; max-width: 200px; }
#${scriptId} .example .audio-btn { background-color: transparent; margin-left: 0.25em; }
#${scriptId} .example .audio-btn.audio-idle { opacity: 50%; }
#${scriptId} .example-text { display: table; white-space: normal; }
#${scriptId} .example-text .title { font-weight: var(--font-weight-bold); }
#${scriptId} .example-text .ja { font-size: var(--font-size-xlarge); }
/* Set the default and on-hover appearance */
#${scriptId} .show-on-hover:hover, #${scriptId} .show-ruby-on-hover:hover ruby rt { background-color: inherit; color: inherit; visibility: visible; }
/* Set the color/appearance of the marked keyword */
#${scriptId} mark, #${scriptId} .show-on-hover:hover mark { background-color: inherit; color: darkcyan; }
/* Set the appearance for show-on-hover and show-on-click elements when trigger state is inactive */
#${scriptId} .show-on-hover, #${scriptId} .show-on-hover mark, #${scriptId} .show-on-click, #${scriptId} .show-on-click mark { background-color: #ccc; color: transparent; text-shadow: none; }
/* Set the appearance for hidden and show-ruby-on-hover elements when trigger state is inactive */
#${scriptId} .show-ruby-on-hover ruby rt { visibility: hidden; }
#${scriptId} .hide { display: none; }
`;
        document.querySelector("head").append(state.styleSheetEl);
    }

    // ---------------------------------------------------------------------------------------------------------------- //
    // ----------------------------------------------------FURIGANA---------------------------------------------------- //
    // ---------------------------------------------------------------------------------------------------------------- //

    function Furigana(expression, expressionWithFurigana) {
        this.expression = expression;
        this.expressionWithFurigana = expressionWithFurigana;
        this.keyword = this.keywordRegex = this.firstKeywordIndex = null;
        this.keywordTag = 'mark';
        this.furiganaSegments = this.parseFurigana(expressionWithFurigana);
    }

    Furigana.prototype.setKeyword = function(keyword) {
        this.keyword = keyword ?? null;
        this.keywordRegex = keyword !== null ? new RegExp(keyword, 'g') : null;
        this.firstKeywordIndex = null; // Reset keyword index
    };

    Furigana.prototype.getExpressionHtml = function() {
        return this.keywordRegex === null ? this.expression : this.expression.replaceAll(this.keywordRegex, `<${this.keywordTag}>$&</${this.keywordTag}>`);
    };

    Furigana.prototype.getFuriganaHtml = function() {
        const normalizedSegments = this.normalizeSegments(this.furiganaSegments, this.expression);
        let html = '';
        for (let i = 0; i < normalizedSegments.length; i++) {
            const {base, furigana} = normalizedSegments[i];
            html += furigana !== null ? `<ruby>${base}<rp>[</rp><rt>${furigana}</rt><rp>]</rp></ruby>` : base;
        }
        return html;
    };

    Furigana.prototype.getFirstKeywordIndex = function() {
        if (this.keyword === null) return -1;
        if (this.firstKeywordIndex !== null) return this.firstKeywordIndex;
        return this.firstKeywordIndex = this.expression.search(this.keywordRegex);
    };

    Furigana.prototype.parseFurigana = function(expressionWithFurigana) {
        const segments = [], regex = /([\u3001-\u303F\u3041-\u3096\u30A0-\u30FF\u3400-\u4DB5\u4E00-\u9FCB\uF900-\uFA6A\uFF01-\uFF5E\uFF5F-\uFF9F]+|[^[\]<> \u4E00-\u9FCB]+)(?:\[([^[\]]+)])?/g;
        let match;

        while ((match = regex.exec(expressionWithFurigana)) !== null) {
            segments.push({base: match[1], furigana: (match[2] ?? null)});
        }
        return segments;
    };

    Furigana.prototype.normalizeSegments = function(segments, expression) {
        const normalizedSegments = [], keywordRegex = this.keywordRegex, keywordTag = this.keywordTag;
        let nextIndex = 0, markStart = 0, markRemaining = 0;
        const keywordMatches = keywordRegex !== null ? Array.from(expression.matchAll(keywordRegex)) : [];

        for (let i = 0; i < segments.length; i++) {
            const curIndex = nextIndex;
            if (i === segments.length - 1) {
                nextIndex = expression.length;
            } else {
                nextIndex = expression.indexOf(segments[i + 1].base[0], nextIndex);
                if (nextIndex === -1)
                    nextIndex = curIndex + segments[i].base.length;
            }
            let matchingSection = expression.substring(curIndex, nextIndex);
            let offset = 0;
            for (let j = 0; j < keywordMatches.length; j++){
                const match = keywordMatches[j];
                if (match.index === undefined) continue;
                const [start, end] = [match.index, match.index + match[0].length];
                if (this.firstKeywordIndex === null && start >= 0)
                    this.firstKeywordIndex = start;
                if (start >= curIndex && start < nextIndex) {
                    markStart = offset + start - curIndex;
                    markRemaining = offset + end - curIndex - markStart;
                }
                if (markRemaining > 0) {
                    const segmentLength = matchingSection.length;
                    const markEnd = Math.min(markStart + markRemaining, segmentLength);
                    const precedingSection = matchingSection.substring(0, markStart);
                    const markedSegment = matchingSection.substring(markStart, markEnd);
                    const remainingSegment = matchingSection.substring(markEnd);
                    matchingSection = `${precedingSection}<${keywordTag}>${markedSegment}</${keywordTag}>${remainingSegment}`;
                    markStart = 0;
                    markRemaining -= Math.min(markRemaining, markedSegment.length);
                    if (remainingSegment.length === 0)
                        break;
                    offset += (keywordTag.length * 2) + 5; // 5 = '<></>'.length
                }
            }
            normalizedSegments.push({ base: matchingSection, furigana: segments[i].furigana });
        }
        return normalizedSegments;
    };
})();