// ==UserScript==
// @name Wanikani Anime Sentences
// @description Adds example sentences from anime movies and shows for vocabulary from immersionkit.com
// @version 1.1.3
// @author psdcon
// @namespace wkanimesentences
// @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=1166918
// @copyright 2021+, Paul Connolly
// @license MIT; http://opensource.org/licenses/MIT
// @run-at document-end
// @grant none
// ==/UserScript==
(() => {
//--------------------------------------------------------------------------------------------------------------//
//-----------------------------------------------INITIALIZATION-------------------------------------------------//
//--------------------------------------------------------------------------------------------------------------//
const wkof = window.wkof;
const scriptId = "anime-sentences";
const scriptName = "Anime Sentences";
let state = {
settings: {
playbackRate: 0.75,
showEnglish: 'onhover',
showJapanese: 'always',
showFurigana: 'onhover',
sentenceLengthSort: 'asc',
filterWaniKaniLevel: true,
filterAnimeShows: {},
filterAnimeMovies: {},
// Some Ghibli films are enabled by default
filterGhibli: {4: true, 5: true, 6: true, 7: true, 8: true},
},
item: null, // current vocab from wkinfo
userLevel: '', // most recent level progression
immersionKitData: null, // cached so sentences can be re-rendered after settings change
sentencesEl: null, // referenced so sentences can be re-rendered after settings change
};
// Titles taken from https://www.immersionkit.com/information
const animeShows = {
0: "Angel Beats!",
1: "Anohana: The Flower We Saw That Day",
2: "Assassination Classroom Season 1",
3: "Bakemonogatari",
4: "Boku no Hero Academia Season 1",
5: "Cardcaptor Sakura",
6: "Chobits",
7: "Clannad",
8: "Clannad After Story",
9: "Code Geass Season 1",
10: "Daily Lives of High School Boys",
11: "Death Note",
12: "Durarara!!",
13: "Erased",
14: "Fairy Tail",
15: "Fate Stay Night UBW Season 1",
16: "Fate Stay Night UBW Season 2",
17: "Fate Zero",
18: "From the New World",
19: "Fruits Basket Season 1",
20: "Fullmetal Alchemist Brotherhood",
21: "God's Blessing on this Wonderful World!",
22: "Haruhi Suzumiya",
23: "Hunter × Hunter",
24: "Is The Order a Rabbit",
25: "K-On!",
26: "Kanon (2006)",
27: "Kill la Kill",
28: "Kino's Journey",
29: "Kokoro Connect",
30: "Little Witch Academia",
31: "Mahou Shoujo Madoka Magica",
32: "My Little Sister Can't Be This Cute",
33: "New Game!",
34: "No Game No Life",
35: "Noragami",
36: "One Week Friends",
37: "Psycho Pass",
38: "Re:Zero − Starting Life in Another World",
39: "Shirokuma Cafe",
40: "Steins Gate",
41: "Sword Art Online",
42: "Toradora!",
43: "Wandering Witch The Journey of Elaina",
44: "Your Lie in April",
}
const animeMovies = {
0: "Only Yesterday",
1: "The Garden of Words",
2: "The Girl Who Leapt Through Time",
3: "The World God Only Knows",
4: "Weathering with You",
5: "Wolf Children",
6: "Your Name",
}
const ghibliTitles = {
0: "Castle in the sky",
1: "From Up on Poppy Hill",
2: "Grave of the Fireflies",
3: "Howl's Moving Castle",
4: "Kiki's Delivery Service",
5: "My Neighbor Totoro",
6: "Princess Mononoke",
7: "Spirited Away",
8: "The Cat Returns",
9: "The Secret World of Arrietty",
10: "The Wind Rises",
11: "When Marnie Was There",
12: "Whisper of the Heart",
}
main();
function main() {
init(() => wkItemInfo.forType(`vocabulary`).under(`examples`).notify(
(item) => onExamplesVisible(item))
);
}
function init(callback) {
createStyle();
if (wkof) {
wkof.include("ItemData,Settings");
wkof
.ready("Apiv2,Settings")
.then(loadSettings)
.then(processLoadedSettings)
.then(getLevel)
.then(callback);
} else {
console.warn(
`${scriptName}: You are not using Wanikani Open Framework which this script utilizes to provide the settings dialog for the script. You can still use ${scriptName} normally though`
);
callback();
}
}
function getLevel() {
wkof.Apiv2.fetch_endpoint('level_progressions', (window.unsafeWindow ?? window).options ?? analyticsOptions).then((response) => {
state.userLevel = response.data[response.data.length - 1].data.level
});
}
function onExamplesVisible(item) {
state.item = item // current vocab item
addAnimeSentences()
}
function addAnimeSentences() {
let parentEl = document.createElement("div");
parentEl.setAttribute("id", 'anime-sentences-parent')
let header = ['Anime Sentences']
const settingsBtn = document.createElement("i");
settingsBtn.setAttribute("class", "fa fa-gear");
settingsBtn.setAttribute("style", "font-size: 14px; cursor: pointer; vertical-align: middle; margin-left: 10px;");
settingsBtn.onclick = openSettings
let sentencesEl = document.createElement("div");
sentencesEl.innerText = 'Loading...'
header.push(settingsBtn)
parentEl.append(sentencesEl)
state.sentencesEl = sentencesEl
if (state.item.injector) {
if (state.item.on === 'lesson') {
state.item.injector.appendAtTop(header, parentEl)
} else { // itemPage, review
state.item.injector.append(header, parentEl)
}
}
const queryString = state.item.characters.replace('〜', ''); // for "counter" kanji
const wkLevelFilter = state.settings.filterWaniKaniLevel ? state.userLevel : '';
let url = `https://api.immersionkit.com/look_up_dictionary?keyword=${queryString}&tags=&jlpt=&wk=${wkLevelFilter}&sort=shortness&category=anime`
fetch(url)
.then(response => response.json())
.then(data => {
state.immersionKitData = data.data[0].examples
renderSentences()
})
}
function getDesiredShows() {
// Convert settings dictionaries to array of titles
let titles = []
for (const [key, value] of Object.entries(state.settings.filterAnimeShows)) {
if (value === true) {
titles.push(animeShows[key])
}
}
for (const [key, value] of Object.entries(state.settings.filterAnimeMovies)) {
if (value === true) {
titles.push(animeMovies[key])
}
}
for (const [key, value] of Object.entries(state.settings.filterGhibli)) {
if (value === true) {
titles.push(ghibliTitles[key])
}
}
return titles
}
function renderSentences() {
// Called from immersionkit response, and on settings save
let examples = state.immersionKitData;
const exampleLenBeforeFilter = examples.length
// Exclude non-selected titles
let desiredTitles = getDesiredShows()
examples = examples.filter(ex => desiredTitles.includes(ex.deck_name))
if (state.settings.sentenceLengthSort === 'asc') {
examples.sort((a, b) => a.sentence.length - b.sentence.length)
} else {
examples.sort((a, b) => b.sentence.length - a.sentence.length)
}
let showJapanese = state.settings.showJapanese;
let showEnglish = state.settings.showEnglish;
let showFurigana = state.settings.showFurigana;
let playbackRate = state.settings.playbackRate
let html = '';
if (exampleLenBeforeFilter === 0) {
html = 'No sentences found.'
} else if (examples.length === 0 && exampleLenBeforeFilter > 0) {
// TODO show which titles have how many examples
html = 'No sentences found for your selected movies & shows.'
} else {
let lim = Math.min(examples.length, 50)
for (var i = 0; i < lim; i++) {
const example = examples[i]
let japaneseText = state.settings.showFurigana === 'never' ?
example.sentence :
new Furigana(example.sentence_with_furigana).ReadingHtml
html += `
<div class="anime-example">
<img src="${example.image_url}" alt="">
<div class="anime-example-text">
<div class="title" title="${example.id}">${example.deck_name}</div>
<div class="ja">
<span class="${showJapanese === 'onhover' ? 'show-on-hover' : ''} ${showFurigana === 'onhover' ? 'show-ruby-on-hover' : ''} ${showJapanese === 'onclick' ? 'show-on-click' : ''}">${japaneseText}</span>
<span><button class="audio-btn audio-idle fa-solid fa-volume-off"></button></span>
<audio src="${example.sound_url}"></audio>
</div>
<div class="en">
<span class="${showEnglish === 'onhover' ? 'show-on-hover' : ''} ${showEnglish === 'onclick' ? 'show-on-click' : ''}">${example.translation}</span>
</div>
</div>
</div>`
}
}
let sentencesEl = state.sentencesEl
sentencesEl.innerHTML = html
let audios = document.querySelectorAll(".anime-example audio")
audios.forEach((a) => {
a.playbackRate = playbackRate
let button = a.parentNode.querySelector('button')
a.onplay = () => {
button.setAttribute("class", "audio-btn audio-play fa-solid fa-volume-high")
};
a.onended = () => {
button.setAttribute('class', "audio-btn audio-idle fa-solid fa-volume-off")
};
})
// Click anywhere plays the audio
let exampleEls = document.querySelectorAll(".anime-example")
exampleEls.forEach((a) => {
a.onclick = function () {
let audio = this.querySelector('audio')
audio.play()
}
});
// Assigning onclick function to .show-on-click elements
document.querySelectorAll(".show-on-click").forEach((a) => {
a.onclick = function () {
this.classList.toggle('show-on-click');
}
});
}
//--------------------------------------------------------------------------------------------------------------//
//----------------------------------------------SETTINGS--------------------------------------------------------//
//--------------------------------------------------------------------------------------------------------------//
function loadSettings() {
return wkof.Settings.load(scriptId, state.settings);
}
function processLoadedSettings() {
state.settings = wkof.settings[scriptId];
}
function openSettings(e) {
e.stopPropagation();
let config = {
script_id: scriptId,
title: scriptName,
on_save: updateSettings,
content: {
general: {
type: "section",
label: "General"
},
sentenceLengthSort: {
type: "dropdown",
label: "Sentence Order",
hover_tip: "",
content: {
asc: "Shortest first",
desc: "Longest first"
},
default: state.settings.sentenceLengthSort
},
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"
},
filterGhibli: {
type: "list",
label: "Ghibli Movies",
multi: true,
size: 6,
hover_tip: "Select which Studio Ghibli movies you'd like to see examples from.",
default: state.settings.filterGhibli,
content: ghibliTitles
},
filterAnimeMovies: {
type: "list",
label: "Anime Movies",
multi: true,
size: 6,
hover_tip: "Select which anime movies you'd like to see examples from.",
default: state.settings.filterAnimeMovies,
content: animeMovies
},
filterAnimeShows: {
type: "list",
label: "Anime Shows",
multi: true,
size: 6,
hover_tip: "Select which anime shows you'd like to see examples from.",
default: state.settings.filterAnimeShows,
content: animeShows
},
filterWaniKaniLevel: {
type: "checkbox",
label: "WaniKani Level",
hover_tip: "Only show sentences with maximum 1 word outside of your current WaniKani level.",
default: state.settings.filterWaniKaniLevel,
},
credits: {
type: "section",
label: "Powered by immersionkit.com"
},
}
};
let dialog = new wkof.Settings(config);
dialog.open();
}
// Called when the user clicks the Save button on the Settings dialog.
function updateSettings() {
state.settings = wkof.settings[scriptId];
renderSentences();
}
//--------------------------------------------------------------------------------------------------------------//
//-----------------------------------------------STYLES---------------------------------------------------------//
//--------------------------------------------------------------------------------------------------------------//
function createStyle() {
const style = document.createElement("style");
style.setAttribute("id", "anime-sentences-style");
// language=CSS
style.innerHTML = `
#anime-sentences-parent > div {
overflow-y: auto;
max-height: 280px;
}
#anime-sentences-parent .fa-solid {
border: none;
font-size: 100%;
}
.anime-example {
display: flex;
align-items: center;
margin-bottom: 1em;
cursor: pointer;
}
.audio-btn {
background-color: transparent;
}
/* Make text and background color the same to hide text */
.anime-example-text .show-on-hover, .anime-example-text .show-on-click {
background: #ccc;
color: #ccc;
text-shadow: none;
}
.anime-example-text .show-on-hover:hover {
background: inherit;
color: inherit
}
/* Furigana hover*/
.anime-example-text .show-ruby-on-hover ruby rt {
visibility: hidden;
}
.anime-example-text:hover .show-ruby-on-hover ruby rt {
visibility: visible;
}
.anime-example .title {
font-weight: 700;
}
.anime-example .ja {
font-size: 2em;
}
.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) {
var segments = ParseFurigana(reading || "");
this.Reading = getReading();
this.Expression = getExpression();
this.Hiragana = getHiragana();
this.ReadingHtml = getReadingHtml();
function getReading() {
var reading = "";
for (var x = 0; x < segments.length; x++) {
reading += segments[x].Reading;
}
return reading.trim();
}
function getExpression() {
var expression = "";
for (var x = 0; x < segments.length; x++)
expression += segments[x].Expression;
return expression;
}
function getHiragana() {
var hiragana = "";
for (var x = 0; x < segments.length; x++) {
hiragana += segments[x].Hiragana;
}
return hiragana;
}
function getReadingHtml() {
var html = "";
for (var x = 0; x < segments.length; x++) {
html += segments[x].ReadingHtml;
}
return html;
}
}
function FuriganaSegment(baseText, furigana) {
this.Expression = baseText;
this.Hiragana = furigana.trim();
this.Reading = baseText + "[" + furigana + "]";
this.ReadingHtml = "<ruby><rb>" + baseText + "</rb><rt>" + furigana + "</rt></ruby>";
}
function UndecoratedSegment(baseText) {
this.Expression = baseText;
this.Hiragana = baseText;
this.Reading = baseText;
this.ReadingHtml = baseText;
}
function ParseFurigana(reading) {
var segments = [];
var currentBase = "";
var currentFurigana = "";
var parsingBaseSection = true;
var parsingHtml = false;
var characters = reading.split('');
while (characters.length > 0) {
var current = characters.shift();
if (current === '[') {
parsingBaseSection = false;
} else if (current === ']') {
nextSegment();
} else if (isLastCharacterInBlock(current, characters) && parsingBaseSection) {
currentBase += current;
nextSegment();
} else if (!parsingBaseSection)
currentFurigana += current;
else
currentBase += current;
}
nextSegment();
function nextSegment() {
if (currentBase)
segments.push(getSegment(currentBase, currentFurigana));
currentBase = "";
currentFurigana = "";
parsingBaseSection = true;
parsingHtml = false;
}
function getSegment(baseText, furigana) {
if (!furigana || furigana.trim().length === 0)
return new UndecoratedSegment(baseText);
return new FuriganaSegment(baseText, furigana);
}
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;
}
})();