// ==UserScript==
// @name Wanikani Anime Sentences 2
// @description Adds example sentences from anime movies and shows for vocabulary from immersionkit.com
// @version 1.2.8
// @author psdcon, edited by Inserio
// @namespace wkanimesentences/inserio
// @match https://www.wanikani.com/*
// @match https://preview.wanikani.com/*
// @require https://gf.qytechs.cn/scripts/430565-wanikani-item-info-injector/code/WaniKani%20Item%20Info%20Injector.user.js?version=1380162
// @copyright 2021+, Paul Connolly
// @license MIT; http://opensource.org/licenses/MIT
// @run-at document-end
// @grant none
// ==/UserScript==
/* global wkof, wkItemInfo */
(() => {
//--------------------------------------------------------------------------------------------------------------//
//-----------------------------------------------INITIALIZATION-------------------------------------------------//
//--------------------------------------------------------------------------------------------------------------//
const wkof = window.wkof, scriptId = "anime-sentences-2", scriptName = "Anime Sentences", styleSheetName = 'anime-sentences-style';
const state = {
settings: {
maxBoxHeight: 320,
playbackRate: 1,
exampleLimit: 100,
showEnglish: 'onhover',
showJapanese: 'always',
showFurigana: 'onhover',
sentenceSorting: 'shortness',
filterExactSearch: true,
filterJLPTLevel: 0,
filterWaniKaniLevel: true,
// Enable all shows and movies by default
filterAnimeShows: [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],
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],
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 wkinfo
item: null,
// most recent level progression
userLevel: '',
// cached so that sentences can be re-rendered after settings change
immersionKitDataCache: null,
// referenced so that sentences can be re-rendered after settings change
sentencesEl: null,
// referenced for quick lookups
audioContainer: 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",
"Mob Psycho 100",
"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",
"Overprotected Kahoko",
"Quartet",
"Sailor Suit and Machine Gun (2006)",
"Smoking",
"The Journalist",
"Weakest Beast",
], // commented-out entries are not queryable via the API (but maybe someday???)
gamesList = [
"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(processLoadedSettings)
.then(() => {
return 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`);
createStyle();
updateDesiredShows();
callback();
}
}
async function getLevel() {
try {
const response = await wkof.Apiv2.fetch_endpoint('level_progressions', (window.unsafeWindow ?? window).options ?? analyticsOptions);
state.userLevel = response.data[response.data.length - 1].data.level;
} catch (error) {
console.error(`Error fetching user level: ${error.message}`);
}
}
async function onExamplesVisible(item) {
state.item = item; // current vocab item
await addAnimeSentences();
}
async function addAnimeSentences() {
const parentEl = document.createElement("div"),
sentencesEl = document.createElement("div"),
settingsBtn = document.createElement("svg"), header = ['Anime Sentences'];
parentEl.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);
parentEl.append(sentencesEl);
state.sentencesEl = sentencesEl;
if (state.item.injector) {
if (state.item.on === 'lesson') {
state.item.injector.appendAtTop(header, parentEl);
} else { // itemPage, review
state.item.injector.append(header, parentEl);
}
}
fetchImmersionKitData().then(examples => {
renderSentences(examples);
});
}
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 = '',
// sentenceSorting = state.settings.sentenceSorting !== 'none' ? `&sort=${state.settings.sentenceSorting}` : '', // removing this so that results can be cached without any sorting
// 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 && state.immersionKitDataCache[url]) {
return Promise.resolve(state.immersionKitDataCache[url]);
}
try {
const response = await fetch(url);
const data = await response.json();
state.immersionKitDataCache = state.immersionKitDataCache || {};
return state.immersionKitDataCache[url] = data.data[0].examples;
} catch (error) {
console.error(`Error fetching immersion kit data: ${error.message}`);
return [];
}
}
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 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 jaSpanClassList = [];
switch (showJapanese) {
case 'always':
break;
case 'onhover':
jaSpanClassList.push('show-on-hover');
break;
case 'onclick':
jaSpanClassList.push('show-on-click');
break;
}
switch (showFurigana) {
case 'always':
break;
case 'onhover':
jaSpanClassList.push('show-ruby-on-hover');
break;
case 'onclick':
jaSpanClassList.push('show-ruby-on-click');
break;
}
const enSpanClassList = [];
switch (showEnglish) {
case 'always':
break;
case 'onhover':
enSpanClassList.push('show-on-hover');
break;
case 'onclick':
enSpanClassList.push('show-on-click');
break;
}
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 = '🔈';
audioButtonEl.onclick = function(e) {
e.stopPropagation();
playAudio(this, example);
};
textTitleEl.append(audioButtonEl);
textParentEl.append(textTitleEl);
const jaEl = document.createElement("div");
jaEl.className = 'ja';
const jaSpanEl = document.createElement("span");
jaSpanEl.classList = jaSpanClassList;
jaSpanEl.innerHTML = japaneseText.ReadingHtml;
jaEl.append(jaSpanEl);
textParentEl.append(jaEl);
const enEl = document.createElement("div");
enEl.className = 'en';
const enSpanEl = document.createElement("span");
enSpanEl.classList = enSpanClassList;
enSpanEl.innerHTML = example.translation;
enEl.append(enSpanEl);
textParentEl.append(enEl);
parentEl.append(textParentEl);
// Click anywhere plays the audio
parentEl.onclick = function () {
const button = this.querySelector('.audio-btn');
button.click();
};
return parentEl;
}
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;
}
}
// Called from immersionkit response, and on settings save
function renderSentences(sentences) {
// Exclude non-selected titles
const examples = [];
//const examples = sentences.filter(ex => state.desiredTitles.has(ex.deck_name) || state.desiredTitles.has(ex.deck_name_japanese));
for (let i = 0; i < sentences.length; i++) {
const ex = sentences[i];
if (state.desiredTitles.has(ex.deck_name) || state.desiredTitles.has(ex.deck_name_japanese))
examples.push(ex);
}
switch (state.settings.sentenceSorting) {
case 'shortness':
examples.sort((a, b) => a.sentence.length - b.sentence.length);
break;
case 'longness':
examples.sort((a, b) => b.sentence.length - a.sentence.length);
break;
}
// state.cachedExampleLimit = ((state.settings.exampleLimit === 0) ? examples.length : Math.min(examples.length, state.settings.exampleLimit));
const fragment = document.createDocumentFragment();
for (let i = 0; i < examples.length; i++) {
const example = examples[i];
const exampleElement = createExampleElement(example);
fragment.appendChild(exampleElement);
}
state.sentencesEl.innerHTML = '';
state.sentencesEl.appendChild(fragment);
attachEventListenersToExamples()
}
function attachEventListenersToExamples() {
// Assigning onclick function to .show-on-click elements
const showOnClickElements = state.sentencesEl.querySelectorAll(".show-on-click");
for (let i = 0; i < showOnClickElements.length; i++) {
const el = showOnClickElements[i];
el.onclick = e => {
e.stopPropagation(); // prevent this click from triggering the audio to play
el.classList.toggle('show-on-click');
};
}
}
function playAudio(button, example) {
if (state.audioContainer) {
state.audioContainer.pause();
const prevSource = state.audioContainer.src;
if (prevSource === example.sound_url) return;
}
state.audioContainer = new Audio(example.sound_url);
state.audioContainer.playbackRate = state.settings.playbackRate;
state.audioContainer.onplay = () => {
button.classList.replace("audio-idle", "audio-play");
button.textContent = '🔊';
};
state.audioContainer.onpause = state.audioContainer.onended = state.audioContainer.onabort = () => {
button.classList.replace("audio-play", "audio-idle");
button.textContent = '🔈';
removeAudioElement(state.audioContainer);
state.audioContainer = null;
};
state.sentencesEl.parentNode.appendChild(state.audioContainer);
return state.audioContainer.play();
}
function removeAudioElement(element) {
if (element === undefined || element === null)
return;
element.src = "";
element.remove();
}
function updateClassListForJapanese() {
const exampleEls = state.sentencesEl.querySelectorAll(".anime-example-text .ja span");
const jaSpanClassList = [];
switch (state.settings.showJapanese) {
case 'always':
break;
case 'onhover':
jaSpanClassList.push('show-on-hover');
break;
case 'onclick':
jaSpanClassList.push('show-on-click');
break;
}
switch (state.settings.showFurigana) {
case 'always':
break;
case 'onhover':
jaSpanClassList.push('show-ruby-on-hover');
break;
case 'onclick':
jaSpanClassList.push('show-ruby-on-click');
break;
}
for (let i = 0; i < exampleEls.length; i++) {
const el = exampleEls[i];
el.classList = jaSpanClassList;
}
}
function updateClassListForEnglish() {
const exampleEls = state.sentencesEl.querySelectorAll(".anime-example-text .en span");
const enSpanClassList = [];
switch (state.settings.showEnglish) {
case 'always':
break;
case 'onhover':
enSpanClassList.push('show-on-hover');
break;
case 'onclick':
enSpanClassList.push('show-on-click');
break;
}
for (let i = 0; i < exampleEls.length; i++) {
const el = exampleEls[i];
el.classList = enSpanClassList;
}
}
// TODO: Use this when updating the associated settings instead of re-creating the entire list
function addClassToElements(className, elements) {
if (className === undefined || className === null
|| elements === undefined || elements === null || elements.length === 0)
return;
for (let i = 0; i < elements.length; i++) {
let element = elements[i];
element.classList.add(className);
}
}
function switchClassForElements(oldClassName, newClassName, elements) {
if (oldClassName === undefined || oldClassName === null
|| newClassName === undefined || newClassName === null
|| elements === undefined || elements === null || elements.length === 0)
return;
// const exampleEls = state.sentencesEl.querySelectorAll(".anime-example");
for (let i = 0; i < elements.length; i++) {
let element = elements[i];
element.classList.replace(oldClassName, newClassName);
}
}
//--------------------------------------------------------------------------------------------------------------//
//----------------------------------------------SETTINGS--------------------------------------------------------//
//--------------------------------------------------------------------------------------------------------------//
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 loadSettings() {
return wkof.Settings.load(scriptId, state.settings);
}
function processLoadedSettings(settings) {
// need to use Object.assign() in order to avoid updating the settings object byref whenever it's saved
Object.assign(state.settings, settings);
}
function openSettings(e) {
e.stopPropagation();
const config = {
script_id: scriptId, title: scriptName, on_save: updateSettings, content: {
general: {
type: "section", label: "General"
}, maxBoxHeight: {
type: "number",
label: "Box Height",
step: 1,
min: 0,
hover_tip: "Set the maximum height of the container box in pixels (px).",
default: state.settings.maxBoxHeight
}, sentenceSorting: {
type: "dropdown", label: "Sentence Sorting Order", hover_tip: "", content: {
none: "Anime Title (alphabetical)", shortness: "Shortest first", longness: "Longest first"
},
default: state.settings.sentenceSorting
}, exampleLimit: {
type: "number",
label: "Example Limit",
step: 1,
min: 0,
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).",
default: state.settings.exampleLimit
}, playbackRate: {
type: "number",
label: "Playback Speed",
step: 0.1,
min: 0.5,
max: 2,
hover_tip: "Speed to play back audio.",
default: state.settings.playbackRate
}, showJapanese: {
type: "dropdown",
label: "Show Japanese",
hover_tip: "When to show Japanese text. Hover enables transcribing a sentences first (play audio by clicking the image to avoid seeing the answer).",
content: {
always: "Always", onhover: "On Hover", onclick: "On Click",
},
default: state.settings.showJapanese
}, showFurigana: {
type: "dropdown",
label: "Show Furigana",
hover_tip: "These have been autogenerated so there may be mistakes.",
content: {
always: "Always", onhover: "On Hover", never: "Never",
},
default: state.settings.showFurigana
}, showEnglish: {
type: "dropdown",
label: "Show English",
hover_tip: "Hover or click allows testing your understanding before seeing the answer.",
content: {
always: "Always", onhover: "On Hover", onclick: "On Click",
},
default: state.settings.showEnglish
}, tooltip: {
type: "section", label: "Filters"
}, filterExactSearch: {
type: "checkbox",
label: "Exact Search",
hover_tip: "Text must match term exactly.\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.",
default: state.settings.filterExactSearch
}, filterAnimeShows: {
type: "list",
label: "Anime Shows",
multi: true,
size: 6,
hover_tip: "Select which anime shows you'd like to see examples from.",
default: state.settings.filterAnimeShows,
content: animeShows
}, filterAnimeMovies: {
type: "list",
label: "Anime Movies",
multi: true,
size: 4,
hover_tip: "Select which anime movies you'd like to see examples from.",
default: state.settings.filterAnimeMovies,
content: animeMovies
}, filterGhibli: {
type: "list",
label: "Ghibli Movies",
multi: true,
size: 4,
hover_tip: "Select which Studio Ghibli movies you'd like to see examples from.",
default: state.settings.filterGhibli,
content: ghibliTitles
}, filterDramas: {
type: "list",
label: "Dramas",
multi: true,
size: 4,
hover_tip: "Select which dramas you'd like to see examples from.",
default: state.settings.filterDramas,
content: dramasList
}, filterGames: {
type: "list",
label: "Games",
multi: true,
size: 3,
hover_tip: "Select which video games you'd like to see examples from.",
default: state.settings.filterGames,
content: gamesList
}, filterLiterature: {
type: "list",
label: "Literature",
multi: true,
size: 6,
hover_tip: "Select which pieces of literature you'd like to see examples from.",
default: state.settings.filterLiterature,
content: literatureList
}, filterNews: {
type: "list",
label: "News",
multi: true,
size: 3,
hover_tip: "Select which news sources you'd like to see examples from.",
default: state.settings.filterNews,
content: newsList
}, filterJLPTLevel: {
type: "dropdown",
label: "JLPT Level",
hover_tip: "Only show sentences matching a particular JLPT Level or easier.",
content: {
0: "No Filter", 1: "N1", 2: "N2", 3: "N3", 4: "N4", 5: "N5"
},
default: state.settings.filterJLPTLevel,
}, filterWaniKaniLevel: {
type: "checkbox",
label: "WaniKani Level",
hover_tip: "Only show sentences with maximum 1 word outside of your current WaniKani level.",
default: state.settings.filterWaniKaniLevel,
}, credits: {
type: "section", label: "Powered by immersionkit.com"
},
}
};
const dialog = new wkof.Settings(config);
dialog.open();
}
// Called when the user clicks the Save button on the Settings dialog.
function updateSettings(updatedSettings) {
let shouldRerender = false;
const {filterAnimeShows, filterAnimeMovies, filterGhibli, filterDramas, filterGames, filterLiterature, filterNews, filterExactSearch, filterJLPTLevel, filterWaniKaniLevel, exampleLimit, playbackRate, sentenceSorting, showFurigana, showJapanese, showEnglish, maxBoxHeight} = state.settings,
{filterAnimeShows: newFilterAnimeShows, filterAnimeMovies: newFilterAnimeMovies, filterGhibli: newFilterGhibli, filterDramas: newFilterDramas, filterGames: newFilterGames, filterLiterature: newFilterLiterature, filterNews: newFilterNews, filterExactSearch: newFilterExactSearch, filterJLPTLevel: newFilterJLPTLevel, filterWaniKaniLevel: newFilterWaniKaniLevel, exampleLimit: newExampleLimit, playbackRate: newPlaybackRate, sentenceSorting: newSentenceSorting, showFurigana: newShowFurigana, showJapanese: newShowJapanese, showEnglish: newShowEnglish, maxBoxHeight: newMaxBoxHeight} = updatedSettings,
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.filterExactSearch = newFilterExactSearch;
state.settings.filterJLPTLevel = newFilterJLPTLevel;
state.settings.filterWaniKaniLevel = newFilterWaniKaniLevel;
state.settings.exampleLimit = newExampleLimit;
state.settings.playbackRate = newPlaybackRate;
state.settings.sentenceSorting = newSentenceSorting;
state.settings.showFurigana = newShowFurigana;
state.settings.showJapanese = newShowJapanese;
state.settings.showEnglish = newShowEnglish;
state.settings.maxBoxHeight = newMaxBoxHeight;
if (maxBoxHeight !== newMaxBoxHeight) {
updateContainerMaxBoxHeight();
}
if (showJapanese !== newShowJapanese || showFurigana !== newShowFurigana || showEnglish !== newShowEnglish) {
updateClassListForJapanese();
updateClassListForEnglish();
}
if (exampleLimit !== newExampleLimit)
adjustExampleLimit(exampleLimit, newExampleLimit);
if (filterExactSearch !== newFilterExactSearch || filterJLPTLevel !== newFilterJLPTLevel || filterWaniKaniLevel !== newFilterWaniKaniLevel || playbackRate !== newPlaybackRate || sentenceSorting !== newSentenceSorting) {
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;
updateDesiredShows();
shouldRerender = true;
}
if (!shouldRerender)
return;
fetchImmersionKitData().then(examples => {
renderSentences(examples);
});
}
//--------------------------------------------------------------------------------------------------------------//
//-----------------------------------------------STYLES---------------------------------------------------------//
//--------------------------------------------------------------------------------------------------------------//
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;
}
#anime-sentences-parent {
border: none;
font-size: 100%;
}
.anime-example {
display: flex;
align-items: center;
margin-bottom: 1em;
cursor: pointer;
}
.anime-example:nth-child(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%;
}
/* Make text and background color the same to hide text */
.anime-example .anime-example-text .show-on-hover, .anime-example-text .show-on-click {
background: #ccc;
color: transparent;
text-shadow: none;
}
.anime-example .anime-example-text .show-on-hover:hover {
background: inherit;
color: inherit
}
/* Color the keyword in the example sentence */
.anime-example .anime-example-text .ja span keyword {
color: darkcyan;
}
/* Furigana hover */
.anime-example .anime-example-text .ja .show-ruby-on-hover ruby rt {
visibility: hidden;
}
.anime-example .anime-example-text:hover .ja .show-ruby-on-hover ruby rt {
visibility: visible;
}
.anime-example .title {
font-weight: var(--font-weight-bold);
}
.anime-example .ja {
font-size: var(--font-size-xlarge);
}
.anime-example img {
margin-right: 1em;
max-width: 200px;
}
`;
document.querySelector("head").append(style);
}
// Adjust the example limit with CSS to avoid recreating the list
function adjustExampleLimit(oldLimit, newLimit) {
const style = document.querySelector(`#${styleSheetName}`);
const pseudoSelectorName = '.anime-example:nth-child';
style.innerHTML = style.innerHTML.replace( `${pseudoSelectorName}(n+${oldLimit+1})`,`${pseudoSelectorName}(n+${newLimit+1})`);
// document.querySelector(`.anime-example:nth-child(n+${state.settings.exampleLimit+1})`);
}
function updateContainerMaxBoxHeight() {
document.querySelector('#anime-sentences-parent > div').style.maxHeight = `${state.settings.maxBoxHeight}px`;
}
//--------------------------------------------------------------------------------------------------------------//
//----------------------------------------------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;
}
})();