您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds example sentences from anime movies and shows for vocabulary from immersionkit.com
当前为
// ==UserScript== // @name Wanikani Anime Sentences 2 // @description Adds example sentences from anime movies and shows for vocabulary from immersionkit.com // @version 2.0.4 // @author Inserio // @namespace wkanimesentences/inserio // @match https://www.wanikani.com/* // @match https://preview.wanikani.com/* // @require https://gf.qytechs.cn/scripts/430565-wanikani-item-info-injector/code/WaniKani%20Item%20Info%20Injector.user.js?version=1380162 // @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 /* global wkof, wkItemInfo */ (() => { const wkof = window.wkof, scriptId = "anime-sentences-2", scriptName = "Anime Sentences", styleSheetName = 'anime-sentences-style'; const state = { settings: { maxBoxHeight: 320, playbackRate: 100, playbackVolume: 100, exampleLimit: 100, fetchRetryCount: 2, fetchRetryDelay: 5000, showEnglish: 'onhover', showJapanese: 'always', showFurigana: 'onhover', sentenceSorting: 'shortness', sentenceSortingSecondary: 'default', filterExactSearch: true, filterJLPTLevel: 0, filterWaniKaniLevel: true, // Enable all shows and movies by default filterAnimeShows: [ 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 ], filterAnimeMovies: [ true, true, true, true, true, true ], filterGhibli: [ true, true, true, true, true, true, true, true, true, true, true, true, true, true ], // Disable all dramas, games, literature, and news by default filterDramas: [ false, false, false, false, false, false, false, false, false, false, false, false, false, false, false ], filterGames: [ false, false, false, false, false, false ], filterLiterature: [ false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false ], filterNews: [ false, false, false, false, false ], }, desiredTitles: null, // current vocab from wkItemInfo item: null, // most recent level progression userLevel: '', // cached so that sentences can be re-rendered after settings change immersionKitDataCache: {}, // referenced so for quick access to the base node baseEl: null, // referenced so that sentences can be re-rendered after settings change sentencesEl: null, }; // Titles taken from https://www.immersionkit.com/information and modified after testing a few example search results // All anime/movies/Ghibli are enabled by default, and all dramas, games, literature, and news are disabled by default const animeShows = [ "Angel Beats!", "Anohana the flower we saw that day", "Assassination Classroom Season 1", "Bakemonogatari", "Boku no Hero Academia Season 1", "Bunny Drop", "Cardcaptor Sakura", "Chobits", "Clannad", "Clannad After Story", "Code Geass Season 1", "Daily Lives of High School Boys", "Death Note", "Demon Slayer - Kimetsu no Yaiba", "Durarara!!", "Erased", "Fairy Tail", "Fate Stay Night Unlimited Blade Works", "Fate Zero", "From the New World", "Fruits Basket Season 1", "Fullmetal Alchemist Brotherhood", "God's Blessing on this Wonderful World!", "Haruhi Suzumiya", "Hunter × Hunter", "Hyouka", "Is The Order a Rabbit", "K-On!", "Kakegurui", "Kanon (2006)", "Kill la Kill", "Kino's Journey", "Kokoro Connect", "Little Witch Academia", "Lucky Star", "Mahou Shoujo Madoka Magica", "Mononoke", "My Little Sister Can't Be This Cute", "New Game!", "Nisekoi", "No Game No Life", "Noragami", "One Week Friends", "Psycho Pass", "Re:Zero − Starting Life in Another World", "ReLIFE", "Shirokuma Cafe", "Sound! Euphonium", "Steins Gate", "Sword Art Online", "The Pet Girl of Sakurasou", "Toradora!", "Wandering Witch The Journey of Elaina", "Your Lie in April", ], animeMovies = [ "The Garden of Words", "The Girl Who Leapt Through Time", "The World God Only Knows", "Weathering with You", "Wolf Children", "Your Name", ], ghibliTitles = [ "Castle in the sky", "From Up on Poppy Hill", "Grave of the Fireflies", "Howl's Moving Castle", "Kiki's Delivery Service", "My Neighbor Totoro", "Only Yesterday", "Princess Mononoke", "Spirited Away", "The Cat Returns", "The Secret World of Arrietty", "The Wind Rises", "When Marnie Was There", "Whisper of the Heart", ], dramasList = [ "1 Litre of Tears", "Border", "Good Morning Call Season 1", "Good Morning Call Season 2", "I am Mita, Your Housekeeper", "I'm Taking the Day Off", "Legal High Season 1", "Million Yen Woman", "Mob Psycho 100", "Overprotected Kahoko", "Quartet", "Sailor Suit and Machine Gun (2006)", "Smoking", "The Journalist", "Weakest Beast", ], gamesList = [ // commented-out entries are not queryable via the API (but maybe someday???) "Cyberpunk 2077", // "NieR:Automata", // "NieR Re[in]carnation", "Skyrim", "Witcher 3", // "Zelda: Breath of the Wild" ], literatureList = [ "黒猫", "おおかみと七ひきのこどもやぎ", "マッチ売りの少女", "サンタクロースがやってきた", "君死にたまふことなかれ 与謝野 晶子", "蝉", "胡瓜", "若鮎について", "黒足袋 吉井 勇", "柿", "お母さんの思ひ出", "砂をかむ", "虻のおれい", "がちゃがちゃ", "犬のいたずら", "犬と人形", "懐中時計", "きのこ会議", "お金とピストル 夢野 久作", "梅のにおい", "純真", "声と人柄", "心の調べ", "愛", "期待と切望", "空の美 宮本 百合子", "いちょうの実", "虔十公園林", "クねずみ", "おきなぐさ", "さるのこしかけ 宮沢 賢治", "セロ弾きのゴーシュ", "ざしき童子のはなし", "秋の歌 寺田 寅彦", "赤い船とつばめ 小川 未明", "赤い蝋燭と人魚 小川 未明", "赤い魚と子供", "秋が きました 小川 未明", "青いボタン", "ある夜の星たちの話", "いろいろな花", "からすとかがし 小川 未明", "片田舎にあった話", "金魚売り", "小鳥と兄妹", "おじいさんが捨てたら", "おかめどんぐり 小川 未明", "お母さん", "お母さんのお乳 小川 未明", "おっぱい", "少年と秋の日", "金のくびかざり 小野 浩", "愛よ愛 岡本 かの子", "気の毒な奥様", "新茶", "初夏に座す", "三角と四角", "赤い蝋燭", "赤とんぼ", "飴だま 新美 南吉", "あし", "がちょうのたんじょうび 新美 南吉", "ごん狐 新美 南吉", "蟹のしょうばい 新美 南吉", "カタツムリノ ウタ", "木の祭り", "こぞうさんのおきょう", "去年の木", "おじいさんのランプ", "王さまと靴屋", "落とした一銭銅貨", "サルト サムライ", "里の春、山の春 新美 南吉", "ウサギ 新美 南吉", "あひるさん と 時計", "川へおちた玉ねぎさん", "小ぐまさんのかんがへちがひ", "お鍋とお皿とカーテン", "お鍋とおやかんとフライパンのけんくわ", "ひらめの学校", "狐物語 林 芙美子", "桜の樹の下には 梶井 基次郎", "瓜子姫子", "ああしんど", "葬式の行列", "風", "子どものすきな神さま", "喫茶店にて", "子供に化けた狐 野口 雨情", "顔", "四季とその折々 黒島 傳冶", ], newsList = [ "平成30年阿蘇神社で甘酒の仕込み始まる", "フレッシュマン!5月号阿蘇広域行政事務組合", "フレッシュマン!7月号春工房、そば処ゆう雀", "フレッシュマン!11月号内牧保育園", "山田小学校で最後の稲刈り", ]; main(); function main() { init(() => wkItemInfo.forType(`vocabulary,kanaVocabulary`).under(`examples`).notify((item) => onExamplesVisible(item))); } function init(callback) { if (wkof) { wkof.include("ItemData,Settings"); wkof.ready("Apiv2,Settings") .then(loadSettings) .then(() => Promise.all([createStyle(), getLevel(), updateDesiredShows()])) .then(callback); } else { console.warn(`${scriptName}: You are not using Wanikani Open Framework which this script utilizes to provide the settings dialog for the script. You can still use ${scriptName} normally though`); Promise.all([createStyle(),updateDesiredShows()]) .then(callback); } } //----------------------------------------------------------------------------------------------------------------// //-----------------------------------------------MAIN FUNCTIONALITY-----------------------------------------------// //----------------------------------------------------------------------------------------------------------------// async function addAnimeSentences() { const baseEl = document.createElement("div"), sentencesEl = document.createElement("div"), settingsBtn = document.createElement("svg"), header = ['Anime Sentences']; baseEl.setAttribute("id", 'anime-sentences-parent'); settingsBtn.setAttribute("style", "font-size: 14px; cursor: pointer; vertical-align: middle; margin-left: 10px;"); settingsBtn.textContent = '⚙️'; settingsBtn.onclick = openSettings; sentencesEl.innerText = 'Loading...'; header.push(settingsBtn); baseEl.append(sentencesEl); state.baseEl = baseEl; state.sentencesEl = sentencesEl; if (state.item.injector) { if (state.item.on === 'lesson') { state.item.injector.appendAtTop(header, baseEl); } else { // itemPage, review state.item.injector.append(header, baseEl); } } let data; for (let i = 0;;) { sentencesEl.innerText = 'Fetching...'; data = await fetchImmersionKitData(); if (i++ >= state.settings.fetchRetryCount || data !== null && data.examples.length > 0) break; const seconds = Math.round(state.settings.fetchRetryDelay/100)/10; // round to nearest first decimal sentencesEl.innerText = `Retrying in ${seconds} second${seconds!==1?'s':''}`; await sleep(state.settings.fetchRetryDelay); } renderSentences(data); } async function fetchImmersionKitData() { let keyword = state.item.characters.replace('〜', ''); // for "counter" kanji if (state.settings.filterExactSearch) keyword = `「${keyword}」`; // TODO: Add &tags= const jlptFilter = state.settings.filterJLPTLevel !== 0 ? `&jlpt=${state.settings.filterJLPTLevel}` : '', wkLevelFilter = state.settings.filterWaniKaniLevel ? `&wk=${state.userLevel}` : '', tags = '', // animeOnly = (state.settings.filterDramas.length === 0 && state.settings.filterGames.length === 0 && state.settings.filterLiterature.length === 0) ? "&category=anime" : '', // this could create a false-negative if the user selects Mob Psycho 100 but nothing else in the non-anime categories, so I'll remove the filter url = `https://api.immersionkit.com/look_up_dictionary?keyword=${keyword}${tags}${jlptFilter}${wkLevelFilter}`; if (state.immersionKitDataCache[url]) return state.immersionKitDataCache[url]; try { const response = await fetch(url), json = await response.json(), data = json.data[0]; return data.examples.length > 0 ? (state.immersionKitDataCache[url] = data) : data; } catch (e) { console.error(`Error fetching immersion kit data: ${e.message}`); return null; } } async function getLevel() { try { const response = await wkof.Apiv2.fetch_endpoint('level_progressions', (window.unsafeWindow ?? window).options ?? analyticsOptions); const entries = response.data; return state.userLevel = entries[entries.length - 1].data.level; } catch (e) { console.error(`Error fetching user level: ${e.message}`); return null; } } async function onExamplesVisible(item) { state.item = item; // current vocab item await addAnimeSentences(); } 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; 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); default: // also handles "default" return primaryOrder; } }); break; case 'none': // need to keep in order to update legacy sentenceSorting values from "none" to "source" 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); default: // also handles "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); default: // also handles "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); default: // also handles "default" return primaryOrder; } }); break; default: // also handles "default" break; } } 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...'; const sentencesToDisplay = []; // Exclude non-selected titles for (let i = 0; i < data.examples.length; i++) { const example = (data.examples)[i]; if (state.desiredTitles.has(example.deck_name) || state.desiredTitles.has(example.deck_name_japanese)) { sentencesToDisplay.push(example); } } if (sentencesToDisplay.length === 0) return state.sentencesEl.innerText = 'No sentences found for your selected movies & shows.'; state.sentencesEl.innerText = ''; 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 = createExampleElement(example); fragment.appendChild(exampleElement); } state.sentencesEl.appendChild(fragment); } function createExampleElement(example) { const showJapanese = state.settings.showJapanese, showEnglish = state.settings.showEnglish, showFurigana = state.settings.showFurigana; const baseKeyword = state.item.characters.replace('〜', ''), baseKeyRegex = new RegExp(baseKeyword.split('').join('\\s*'), 'g'); // intersperse whitespace quantifier to match awkwardly spaced out sentences const keywordSet = new Set(); // use a set to prevent duplicates from being added if (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 japaneseText = new Furigana(example.sentence_with_furigana); const keywords = Array.from(keywordSet); const keyRegex = (keywords.length === 0 || baseKeyword === keywords.join('')) ? baseKeyRegex : new RegExp(keywords.join('|'), 'g'); // avoid new RegEx creation unless necessary; use alternation when using the example's word_list (which will end up creating tags around each "word") surroundMatchingKeywordsWithHtmlTag(keyRegex, example, japaneseText); const parentEl = document.createElement("div"); parentEl.className = 'anime-example'; const imgEl = document.createElement("img"); imgEl.src = example.image_url ?? ""; imgEl.decoding = "auto"; imgEl.alt = ''; parentEl.append(imgEl); const textParentEl = document.createElement("div"); textParentEl.className = 'anime-example-text'; const textTitleEl = document.createElement("div"); textTitleEl.className = 'title'; textTitleEl.title = example.id; 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); textTitleEl.append(audioButtonEl); textParentEl.append(textTitleEl); const jaEl = document.createElement("div"); jaEl.className = 'ja'; const jaSpanEl = document.createElement("span"); updateClassListForSpanElement(jaSpanEl, 'showJapanese', showJapanese); updateClassListForSpanElement(jaSpanEl, 'showFurigana', showFurigana); updateOnClickListenerForSpanElement(jaSpanEl, 'showJapanese', showJapanese); jaSpanEl.innerHTML = japaneseText.ReadingHtml; jaEl.append(jaSpanEl); textParentEl.append(jaEl); const enEl = document.createElement("div"); enEl.className = 'en'; const enSpanEl = document.createElement("span"); updateClassListForSpanElement(enSpanEl, 'showEnglish', showEnglish); updateOnClickListenerForSpanElement(enSpanEl, 'showEnglish', showEnglish); enSpanEl.innerHTML = example.translation; enEl.append(enSpanEl); textParentEl.append(enEl); parentEl.append(textParentEl); attachAudioOnClickListener(parentEl); return parentEl; } //----------------------------------------------------------------------------------------------------------------// //----------------------------------------------------HELPERS-----------------------------------------------------// //----------------------------------------------------------------------------------------------------------------// function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } 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 surroundMatchingKeywordsWithHtmlTag(keyRegex, example, japaneseText) { keyRegex.lastIndex = 0; if (!keyRegex.test(example.sentence)) { return; } let startIndex = -1, endIndex = 0, match; for (let j = 0; j < japaneseText.Segments.length; j++) { const segment = japaneseText.Segments[j]; keyRegex.lastIndex = 0; if (keyRegex.test(segment.Expression)) { // the entire match is contained within segment segment.Expression = segment.Expression.replace(keyRegex, '<keyword>$&</keyword>'); startIndex = -1; endIndex = 0; continue; } // match is likely split between multiple segments, so we'll parse ahead to find them if (endIndex <= 0 || startIndex <= -1) { // no match has been found yet let combinedExpression = ""; for (let k = j; k < japaneseText.Segments.length; k++) { combinedExpression += japaneseText.Segments[k].Expression; match = keyRegex.exec(combinedExpression); if (match !== null) { startIndex = match.index; endIndex = startIndex + match[0].length; break; } } } if (endIndex <= 0 || startIndex <= -1) break; // no match was found through the remainder of the sentence // complete match was found within index 'j' to index 'k' by combining the segments if (startIndex - segment.Expression.length >= 0) { // the current segment is prior to the start of the match startIndex -= segment.Expression.length; endIndex -= segment.Expression.length; continue; } // match is present within the current segment // new expression should consist of (current segment prior to the match) (open tag) (match) (close tag) (remainder of segment) let newSegment; if (endIndex - segment.Expression.length < 0) { // the current segment contains the remainder of the match newSegment = `${segment.Expression.substring(0, startIndex)}<keyword>${segment.Expression.substring(startIndex, endIndex - startIndex)}</keyword>${segment.Expression.substring(endIndex)}`; } else { // match continues to the following segment newSegment = `${segment.Expression.substring(0, startIndex)}<keyword>${segment.Expression.substring(startIndex)}</keyword>`; } startIndex = 0; // start any subsequent segments with an open tag at the beginning endIndex -= segment.Expression.length; // subtract used segment from index // replace the current segment with a new expression with surrounding tags segment.Expression = newSegment; } } //----------------------------------------------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(); } function updateClassListForSpanElement(element, name, value) { const isFurigana = name === 'showFurigana'; const subclass = isFurigana ? '-ruby' : ''; switch (value) { case 'always': if (isFurigana) { element.classList.toggle(`show${subclass}-on-hover`, false); break; } element.classList.toggle(`show-on-click`, false); element.classList.toggle(`show-on-hover`, false); break; case 'never': element.classList.toggle(`show${subclass}-on-hover`, false); element.classList.toggle("hide", true); break; case 'onclick': element.classList.toggle(`show${subclass}-on-hover`, false); element.classList.toggle(`show-on-click`, true); break; case 'onhover': if (!isFurigana) element.classList.toggle(`show-on-click`, false); element.classList.toggle(`show${subclass}-on-hover`, true); break; default: return; } } //----------------------------------------------------ON CLICK----------------------------------------------------// function updateOnClickListenerForSpanElement(element, name, value) { switch (value) { case 'always': case 'onhover': case 'never': if (name !== 'showFurigana') removeOnClickEventListener(element); break; case 'onclick': 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(); }; } 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'); }; } function removeOnClickEventListener(element) { element.onclick = null; } //----------------------------------------------------------------------------------------------------------------// //----------------------------------------------------SETTINGS----------------------------------------------------// //----------------------------------------------------------------------------------------------------------------// 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, fetchRetryDelay, filterAnimeMovies, filterAnimeShows, filterDramas, 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 } = state.settings; const { exampleLimit: newExampleLimit, fetchRetryCount: newFetchRetryCount, fetchRetryDelay: newFetchRetryDelay, filterAnimeMovies: newFilterAnimeMovies, filterAnimeShows: newFilterAnimeShows, filterDramas: newFilterDramas, filterExactSearch: newFilterExactSearch, filterGames: newFilterGames, filterGhibli: newFilterGhibli, filterJLPTLevel: newFilterJLPTLevel, filterLiterature: newFilterLiterature, filterNews: newFilterNews, filterWaniKaniLevel: newFilterWaniKaniLevel, maxBoxHeight: newMaxBoxHeight, playbackRate: newPlaybackRate, playbackVolume: newPlaybackVolume, sentenceSorting: newSentenceSorting, sentenceSortingSecondary: newSentenceSortingSecondary, showEnglish: newShowEnglish, showFurigana: newShowFurigana, showJapanese: newShowJapanese, } = updatedSettings; const animeShowsListsDiffer = !arrayValuesEqual(filterAnimeShows,newFilterAnimeShows), animeMoviesListsDiffer = !arrayValuesEqual(filterAnimeMovies,newFilterAnimeMovies), ghibliListsDiffer = !arrayValuesEqual(filterGhibli,newFilterGhibli), dramasListsDiffer = !arrayValuesEqual(filterDramas,newFilterDramas), gamesListsDiffer = !arrayValuesEqual(filterGames,newFilterGames), literatureListsDiffer = !arrayValuesEqual(filterLiterature,newFilterLiterature), newsListsDiffer = !arrayValuesEqual(filterNews,newFilterNews); // avoid many issues by updating the values manually exactly as desired state.settings.exampleLimit = newExampleLimit; state.settings.fetchRetryCount = newFetchRetryCount; state.settings.fetchRetryDelay = newFetchRetryDelay; state.settings.filterExactSearch = newFilterExactSearch; state.settings.filterJLPTLevel = newFilterJLPTLevel; state.settings.filterWaniKaniLevel = newFilterWaniKaniLevel; state.settings.maxBoxHeight = newMaxBoxHeight; state.settings.playbackRate = newPlaybackRate; state.settings.playbackVolume = newPlaybackVolume; state.settings.sentenceSorting = newSentenceSorting; state.settings.sentenceSortingSecondary = newSentenceSortingSecondary; state.settings.showEnglish = newShowEnglish; state.settings.showFurigana = newShowFurigana; state.settings.showJapanese = newShowJapanese; if (filterExactSearch !== newFilterExactSearch || filterJLPTLevel !== newFilterJLPTLevel || filterWaniKaniLevel !== newFilterWaniKaniLevel || sentenceSorting !== newSentenceSorting || sentenceSortingSecondary !== newSentenceSortingSecondary) { shouldRerender = true; } if (animeShowsListsDiffer || animeMoviesListsDiffer || ghibliListsDiffer || dramasListsDiffer || gamesListsDiffer || literatureListsDiffer || newsListsDiffer) { state.settings.filterAnimeShows = animeShowsListsDiffer ? Object.values(newFilterAnimeShows) : filterAnimeShows; state.settings.filterAnimeMovies = animeMoviesListsDiffer ? Object.values(newFilterAnimeMovies) : filterAnimeMovies; state.settings.filterGhibli = ghibliListsDiffer ? Object.values(newFilterGhibli) : filterGhibli; state.settings.filterDramas = dramasListsDiffer ? Object.values(newFilterDramas) : filterDramas; state.settings.filterGames = gamesListsDiffer ? Object.values(newFilterGames) : filterGames; state.settings.filterLiterature = literatureListsDiffer ? Object.values(newFilterLiterature) : filterLiterature; state.settings.filterNews = newsListsDiffer ? Object.values(newFilterNews) : filterNews; await updateDesiredShows(); shouldRerender = true; } if (!shouldRerender) return; let data; for (let i = 0;;) { data = await fetchImmersionKitData(); if (i++ >= state.settings.fetchRetryCount || data !== null && data.examples.length > 0) break; await sleep(state.settings.fetchRetryDelay); } renderSentences(data); } async function loadSettings() { try { const settings = await wkof.Settings.load(scriptId, state.settings); // update legacy playbackRate settings from being a decimal value to being a raw percentage out of 200 if (settings.playbackRate <= 2) settings.playbackRate = settings.playbackRate * 50; // need to use Object.assign() in order to avoid updating the state.settings object byref whenever it's saved Object.assign(state.settings, settings); } catch (e) { console.error(`Error loading settings from WaniKani Open Framework: ${e.message}`); } } 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" }; function getSecondarySortingMethods(primarySorting) { const sortingMethodsCopy = Object.assign({}, sortingMethods); switch (primarySorting) { case 'category': delete sortingMethodsCopy.category; return sortingMethodsCopy; case 'source': delete sortingMethodsCopy.source; delete sortingMethodsCopy.category; return sortingMethodsCopy; case 'shortness': delete sortingMethodsCopy.shortness; delete sortingMethodsCopy.longness; return sortingMethodsCopy; case 'longness': delete sortingMethodsCopy.shortness; delete sortingMethodsCopy.longness; return sortingMethodsCopy; default: // also handles "default" delete sortingMethodsCopy.source; delete sortingMethodsCopy.category; delete sortingMethodsCopy.shortness; delete sortingMethodsCopy.longness; return sortingMethodsCopy; } } function getMissingSortingMethods(currentSecondarySortingOptions) { return Object.entries(sortingMethods).filter(([key]) => currentSecondarySortingOptions[key] === undefined); } function onPrimarySortOptionChanged(name, value) { // TODO: This method is a cursed way of handling this and should be replaced by a natively available method via WKOF if/when I can figure one out. const options = this.parentNode.parentNode.nextSibling.lastChild.lastChild.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': break; case 'source': options.remove(options.namedItem('category').index); break; case 'shortness': options.remove(options.namedItem('longness').index); break; case 'longness': options.remove(options.namedItem('shortness').index); break; default: // also handles "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); return; } options.remove(options.namedItem(value).index); } 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: { maxBoxHeight: { type: "number", label: "Box Height (px)", step: 1, min: 0, default: state.settings.maxBoxHeight, hover_tip: "Set the maximum height of the container box in pixels.", on_change: onMaxBoxHeightOptionChanged }, 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, //refresh_on_change: true, 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: { filterAnimeShows: { type: "list", label: "Anime Shows", multi: true, size: 6, default: state.settings.filterAnimeShows, 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: 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: ghibliTitles, 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: dramasList, 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: gamesList, 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: literatureList, 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: newsList, 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.\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: "section", label: "Powered by immersionkit.com" }, } }; const dialog = new wkof.Settings(settingsConfig); dialog.open(); } async function updateDesiredShows() { // Combine settings objects to a single hashmap of titles const largestArrayLength = Math.max(state.settings.filterAnimeShows.length, state.settings.filterAnimeMovies.length, state.settings.filterGhibli.length, state.settings.filterDramas.length, state.settings.filterGames.length, state.settings.filterLiterature.length, state.settings.filterNews.length); const titles = new Map(); // ordering doesn't matter, so we'll add from all lists simultaneously for (let i = 0; i < largestArrayLength; i++) { if (i < animeShows.length && i < state.settings.filterAnimeShows.length && state.settings.filterAnimeShows[i]) titles.set(animeShows[i],true); if (i < animeMovies.length && i < state.settings.filterAnimeMovies.length && state.settings.filterAnimeMovies[i]) titles.set(animeMovies[i],true); if (i < ghibliTitles.length && i < state.settings.filterGhibli.length && state.settings.filterGhibli[i]) titles.set(ghibliTitles[i],true); if (i < dramasList.length && i < state.settings.filterDramas.length && state.settings.filterDramas[i]) titles.set(dramasList[i],true); if (i < gamesList.length && i < state.settings.filterGames.length && state.settings.filterGames[i]) titles.set(gamesList[i],true); if (i < literatureList.length && i < state.settings.filterLiterature.length && state.settings.filterLiterature[i]) titles.set(literatureList[i],true); if (i < newsList.length && i < state.settings.filterNews.length && state.settings.filterNews[i]) titles.set(newsList[i],true); } state.desiredTitles = titles; } function onAudioPlaybackOptionChanged(name, value) { const audioContainer = state.baseEl.querySelector("audio"); if (audioContainer === null) return; switch (name) { case "playbackRate": audioContainer.playbackRate = value * 2 / 100; break; case "playbackVolume": audioContainer.volume = value / 100; break; } } function onExampleLimitOptionChanged(name, value) { // Adjust the example limit with CSS to avoid recreating the list const style = document.querySelector(`#${styleSheetName}`); const searchText = /(\.anime-example:nth-child)(\(n\+\d+\))?/; const replaceText = value===0 ? '$1' : `$1(n+${value+1})`; style.innerHTML = style.innerHTML.replace(searchText,replaceText); } function onFetchOptionChanged(name, value) { switch (name) { case "fetchRetryCount": state.settings.fetchRetryCount = value; break; case "fetchRetryDelay": state.settings.fetchRetryDelay = value; break; } } function onMaxBoxHeightOptionChanged(name, value) { document.querySelector('#anime-sentences-parent > div').style.maxHeight = `${value}px`; } function onTextShowOptionChanged(name, value) { let selector; switch (name) { case "showFurigana": case "showJapanese": selector = '.anime-example-text .ja span'; break; case "showEnglish": selector = '.anime-example-text .en span'; break; default: return; } const exampleEls = state.sentencesEl.querySelectorAll(selector); for (let i = 0; i < exampleEls.length; i++) { const el = exampleEls[i]; updateClassListForSpanElement(el, name, value); updateOnClickListenerForSpanElement(el, name, value); } } async function onSettingsClosed(settings) { // Revert any modifications that were unsaved, or finalize any that were. 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); } function validatePlaybackRate(value) { return {valid: value >= 5, msg: `${value * 2}%`}; } function validatePlaybackVolume(value) { return {valid: true, msg: `${value}%`}; } //----------------------------------------------------------------------------------------------------------------// //-----------------------------------------------------STYLES-----------------------------------------------------// //----------------------------------------------------------------------------------------------------------------// async function createStyle() { const style = document.createElement("style"); style.setAttribute("id", styleSheetName); // language=CSS style.innerHTML = ` #anime-sentences-parent > div { overflow-y: auto; max-height: ${state.settings.maxBoxHeight}px; display: table; white-space:normal; } #anime-sentences-parent { border: none; font-size: 100%; } .anime-example { display: flex; align-items: center; margin-bottom: 1em; cursor: pointer; } .anime-example:nth-child${state.settings.exampleLimit===0?'':'(n+'+state.settings.exampleLimit+1+')'} { display: none; } .anime-example .audio-btn { background-color: transparent; margin-left: 0.25em; } .anime-example .audio-btn.audio-idle { opacity: 50%; } .anime-example .anime-example-text .show-on-hover:hover { background: inherit; color: inherit; } /* Color the keyword in the example sentence */ .anime-example .anime-example-text .ja span:not(.show-on-click):not(.show-on-hover) keyword { color: darkcyan; } /* Make text and background color the same to hide text */ .anime-example .anime-example-text span.show-on-hover,.anime-example .anime-example-text span.show-on-click { background: #ccc; color: transparent; text-shadow: none; } /* Furigana hover */ .anime-example .anime-example-text .ja span.hide ruby rt, .anime-example .anime-example-text .ja span.show-ruby-on-hover ruby rt { visibility: hidden; } .anime-example .anime-example-text .ja span ruby rt, .anime-example .anime-example-text:hover .ja span.show-ruby-on-hover ruby rt { visibility: visible; } .anime-example .title { font-weight: var(--font-weight-bold); } .anime-example .ja { font-size: var(--font-size-xlarge); } .anime-example img { margin-right: 1em; max-width: 200px; } `; document.querySelector("head").append(style); } //----------------------------------------------------------------------------------------------------------------// //----------------------------------------------------FURIGANA----------------------------------------------------// //----------------------------------------------------------------------------------------------------------------// // https://raw.githubusercontent.com/helephant/Gem/master/src/Gem.Javascript/gem.furigana.js function Furigana(reading) { this.Segments = ParseFurigana(reading || ""); this.Reading = (() => ({ toString: () => { let reading = ""; for (let x = 0; x < this.Segments.length; x++) { reading += this.Segments[x].Reading; } return reading.trim(); } }))(); this.Expression = (() => ({ toString: () => { let expression = ""; for (let x = 0; x < this.Segments.length; x++) expression += this.Segments[x].Expression; return expression; } }))(); this.Hiragana = (() => ({ toString: () => { let hiragana = ""; for (let x = 0; x < this.Segments.length; x++) { hiragana += this.Segments[x].Hiragana; } return hiragana; } }))(); this.ReadingHtml = (() => ({ toString: () => { let html = ""; for (let x = 0; x < this.Segments.length; x++) { html += this.Segments[x].ReadingHtml; } return html; } }))(); } function FuriganaSegment(baseText, furigana) { this.Expression = baseText; this.Hiragana = furigana.trim(); this.Reading = (() => ({toString: () => `${this.Expression}[${this.Hiragana}]`}))(); this.ReadingHtml = (() => ({toString: () => `<ruby>${this.Expression}<rp>[</rp><rt>${this.Hiragana}</rt><rp>]</rp></ruby>`}))(); } function UndecoratedSegment(baseText) { this.Expression = baseText; this.Hiragana = (() => ({toString: () => this.Expression}))(); this.Reading = (() => ({toString: () => this.Expression}))(); this.ReadingHtml = (() => ({toString: () => this.Expression}))(); } function ParseFurigana(reading) { let currentBase = "", currentFurigana = "", parsingBaseSection = true, parsingHtml = false; const segments = [], characters = reading.split(''); while (characters.length > 0) { const current = characters.shift(); if (current === '[') { parsingBaseSection = false; } else if (current === '<') { parsingHtml = true; } else if (current === ']') { nextSegment(); } else if (parsingBaseSection && !parsingHtml && isLastCharacterInBlock(current, characters)) { currentBase += current; nextSegment(); } else if (parsingHtml && current === '>') { parsingHtml = false; } else if (!parsingBaseSection && !parsingHtml) { currentFurigana += current; } else { currentBase += current; } } nextSegment(); function nextSegment() { if (currentBase) segments.push(getSegment(currentBase, currentFurigana)); currentBase = ""; currentFurigana = ""; parsingBaseSection = true; parsingHtml = false; } function getSegment(baseText, furigana) { if (!furigana || furigana.trim().length === 0) return new UndecoratedSegment(baseText); return new FuriganaSegment(baseText, furigana); } function isLastCharacterInBlock(current, characters) { return !characters.length || (isKanji(current) !== isKanji(characters[0]) && characters[0] !== '['); } function isKanji(character) { return character && character.charCodeAt(0) >= 0x4e00 && character.charCodeAt(0) <= 0x9faf; } return segments; } })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址