您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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; }; })();