"use strict";
// ==UserScript==
// @name Advanced Context Sentence
// @namespace https://openuserjs.org/users/abdullahalt
// @version 1.40
// @description Enhance the context sentence section, highlighting kanji and adding audio
// @author abdullahalt
// @match https://www.wanikani.com/lesson/session
// @match https://www.wanikani.com/review/session
// @match https://www.wanikani.com/vocabulary/*
// @grant none
// @require https://unpkg.com/popper.js@1/dist/umd/popper.min.js
// @require https://unpkg.com/tippy.js@4
// @copyright 2019, abdullahalt (https://openuserjs.org//users/abdullahalt)
// @license MIT
// ==/UserScript==
// ==OpenUserJS==
// @author abdullahalt
// ==/OpenUserJS==
(() => {
//--------------------------------------------------------------------------------------------------------------//
//-----------------------------------------------INITIALIZATION-------------------------------------------------//
//--------------------------------------------------------------------------------------------------------------//
const wkof = window.wkof;
const scriptId = "AdvancedContextSentence";
const scriptName = "Advanced Context Sentence";
const vocabularyPage = "/vocabulary";
const recognizedSelector = "a.recognized";
const unrecognizedSelector = "a.unrecognized";
const sessions = [
{
page: "/review/session",
mount: "#item-info-col2",
loading: "#loading",
getHeader: sentences => {
return sentences[0].previousElementSibling;
}
},
{
page: "/lesson/session",
mount: "#supplement-voc-context-sentence",
loading: "#loading-screen",
getHeader: sentences => {
return sentences[0].parentElement.previousElementSibling;
}
}
];
let state = {
settings: {
recognizedKanjiColor: "#f100a1",
unrecognizedKanjiColor: "#888888",
recognitionLevel: "5",
tooltip: {
show: true,
delay: 0,
position: "top"
}
},
kanjis: [],
jiff: false // JLPT, Joyo and Frequency Filters
};
// Application start Point
main();
function main() {
// we don't need to observe any changes in the vocabulary page
if (isPage(vocabularyPage)) {
init(() =>
evolveContextSentence(sentences => {
return sentences[0].previousElementSibling;
})
);
return;
}
// Get the target for the session page to watch for changes
const session = getSessionDependingOnPage();
if (session) startObserving(session);
}
function startObserving({ mount, loading, getHeader }) {
const loadingObservationConfiguration = {
attributes: true,
childList: false,
subtree: false
};
const itemInfoObservationConfiguration = {
attributes: false,
childList: true,
subtree: false
};
const observeLoading = () => {
observeChanges({
element: loading,
config: loadingObservationConfiguration,
onChange: runInit
});
};
const runInit = () => {
init(() => {
observeSentenceChanges();
});
};
const observeSentenceChanges = () => {
observeChanges({
element: mount,
continuesObservation: true,
config: itemInfoObservationConfiguration,
onChange: () => evolve(),
onInitObserver: () => evolve()
});
};
const evolve = () => evolveContextSentence(getHeader);
/**
* Basically, this function will fire an observer that will
* watch when the loading screen on the session pages (lesson and review) stops,
* then it will fire another observer to watch for changing the sentences,
* whenever the sentence change it will fire the evolveContextSentence over it again
*
* why wait for the loading screen stops? because the script slows down the animation
* which makes a really bad user experience
*/
observeLoading();
}
function init(callback) {
createReferrer();
createStyle();
if (wkof) {
wkof.include("ItemData,Settings");
wkof
.ready("ItemData,Settings")
.then(loadSettings)
.then(proccessLoadedSettings)
.then(getKanji)
.then(extractKanjiFromResponse)
.then(callback);
} else {
console.warn(
`${scriptName}: You are not using Wanikani Open Framework which this script utlizes to see the kanji you learned and highlights it with a different color, it also provides the settings dailog for the scrip. You can still use Advanced Context Sentence normally though`
);
callback();
}
}
function evolveContextSentence(getHeader) {
const sentences = document.querySelectorAll(".context-sentence-group");
if (sentences.length === 0) return;
if (wkof) evolveHeader(getHeader(sentences));
sentences.forEach(sentence => {
const japaneseSentence = sentence.querySelector('p[lang="ja"]');
const audioButton = createAudioButton(japaneseSentence.innerHTML);
//let advancedExampleSentence = "";
const chars = japaneseSentence.innerHTML.split("");
japaneseSentence.innerHTML = "";
chars.forEach(char => {
japaneseSentence.innerHTML =
japaneseSentence.innerHTML + tagAndLinkKanji(char).trim();
});
highlightKanji();
japaneseSentence.append(audioButton);
});
}
function evolveHeader(header) {
const settings = document.createElement("i");
settings.setAttribute("class", "icon-gear");
settings.setAttribute(
"style",
"font-size: 14px; cursor: pointer; vertical-align: middle; margin-left: 10px;"
);
settings.onclick = openSettings;
if (!header.querySelector("i.icon-gear")) header.append(settings);
}
/**
* To fix a weird issue that occur in the session pages(where all audios play
* if the audio for reading the word is clicked),
* we have to create the audio element only for the time of palying the audio
* and remove it afterward
* @param {*} sentence
*/
function createAudioButton(sentence) {
// contains audio and button as sibiling elements
const audioContainer = document.createElement("span");
const mpegSource = createSource("audio/mpeg", sentence);
const oogSource = createSource("audio/oog", sentence);
const button = document.createElement("button");
button.setAttribute("class", "audio-btn audio-idle");
button.onclick = () => {
if (audioContainer.childElementCount > 1) {
const audio = audioContainer.querySelector("audio");
audio.pause();
button.setAttribute("class", "audio-btn audio-idle");
audio.remove();
return;
}
const audio = document.createElement("audio");
audio.setAttribute("display", "none");
audio.append(mpegSource, oogSource);
audio.onplay = () => {
button.setAttribute("class", "audio-btn audio-play");
};
audio.onended = () => {
button.setAttribute("class", "audio-btn audio-idle");
audio.remove();
};
audioContainer.append(audio);
audio.play();
};
audioContainer.append(button);
return audioContainer;
}
function observeChanges(params) {
const {
element,
config,
onChange,
onInitObserver = () => {},
continuesObservation = false
} = params;
if (!window.MutationObserver) {
console.warn(
`${scriptName}: you're browser does not support MutationObserver which this script utilaizes to implement its features in /lesson/session and /review/sesson. update you're broswer or use another one if you want Advanced Context Sentence to work on them. This script is still useful on /vocabulary page though`
);
return;
}
onInitObserver();
const target = document.querySelector(element);
const observer = new MutationObserver(() => {
observer.disconnect();
onChange();
continuesObservation && observer.observe(target, config);
});
observer.observe(target, config);
}
//--------------------------------------------------------------------------------------------------------------//
//----------------------------------------------SETTINGS--------------------------------------------------------//
//--------------------------------------------------------------------------------------------------------------//
function loadSettings() {
return wkof.Settings.load(scriptId, state.settings);
}
function proccessLoadedSettings() {
state.settings = wkof.settings[scriptId];
}
function openSettings() {
var config = {
script_id: scriptId,
title: scriptName,
on_save: updateSettings,
content: {
highlightColors: {
type: "section",
label: "Highlights"
},
recognizedKanjiColor: {
type: "color",
label: "Recognized Kanji",
hover_tip:
"Kanji you should be able to recognize will be highlighted using this color",
default: state.settings.recognizedKanjiColor
},
unrecognizedKanjiColor: {
type: "color",
label: "Unrecognized Kanji",
hover_tip:
"Kanji you shouldn't be able to recognize will be highlighted using this color",
default: state.settings.unrecognizedKanjiColor
},
recognitionLevel: {
type: "dropdown",
label: "Recognition Level",
hover_tip:
"Any kanji with this level or higher will be highlighted with the 'Recognized Kanji' color",
default: state.settings.recognitionLevel,
content: {
1: stringfySrs(1),
2: stringfySrs(2),
3: stringfySrs(3),
4: stringfySrs(4),
5: stringfySrs(5),
6: stringfySrs(6),
7: stringfySrs(7),
8: stringfySrs(8),
9: stringfySrs(9)
}
},
tooltip: {
type: "section",
label: "Tooltip"
},
show: {
type: "checkbox",
label: "Show Tooltip",
hover_tip:
"Display a tooltip when hovering on kanji that will display some of its properties",
default: state.settings.tooltip.show,
path: "@tooltip.show"
},
delay: {
type: "number",
label: "Delay",
hover_tip: "Delay in ms before the tooltip is shown",
default: state.settings.tooltip.delay,
path: "@tooltip.delay"
},
position: {
type: "dropdown",
label: "Position",
hover_tip: "The placement of the tooltip",
default: state.settings.tooltip.position,
path: "@tooltip.position",
content: {
top: "Top",
bottom: "Bottom",
right: "Right",
left: "Left"
}
}
}
};
var 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];
highlightKanji();
}
//---------------------------------------------------------------------------------------------------------------//
//-------------------------------------------HELPER FUNCTIONS----------------------------------------------------//
//---------------------------------------------------------------------------------------------------------------//
function isPage(page) {
const path = window.location.pathname;
return path.includes(page);
}
function getSessionDependingOnPage() {
let result = null;
sessions.forEach(session => {
if (isPage(session.page)) result = session;
});
return result;
}
function tagAndLinkKanji(char) {
return isKanji(char) ? wrapInAnchor(char).outerHTML : char;
}
/**
* Determine if the character is a Kanji, inspired by https://stackoverflow.com/a/15034560
*/
function isKanji(char) {
return isCommonOrUncommonKanji(char) || isRareKanji(char);
}
function isCommonOrUncommonKanji(char) {
return char >= "\u4e00" && char <= "\u9faf";
}
function isRareKanji(char) {
char >= "\u3400" && char <= "\u4dbf";
}
/**
* Renders the link for the kanji
* Knji pages always use https://www.wanikani.com/kanji/{kanji} where {kanji} is the kanji character
*/
function wrapInAnchor(char) {
const anchor = document.createElement("a");
anchor.setAttribute("target", "_blank");
anchor.setAttribute("class", "recognized");
if (!wkof) {
anchor.setAttribute("href", `https://www.wanikani.com/kanji/${char}`);
anchor.innerText = char;
return anchor;
}
const kanji = state.kanjis.find(item => item.char == char);
anchor.setAttribute("data-srs", kanji ? kanji.srs : -1);
anchor.setAttribute("data-kanji", char);
anchor.setAttribute(
"href",
kanji ? kanji.url : `https://jisho.org/search/${char}`
);
anchor.innerText = char;
return anchor;
}
function createTooltip(kanji) {
if (!wkof) {
const container = document.createElement("span");
return container;
}
const container = document.createElement("div");
container.setAttribute("class", "acs-tooltip");
if (!kanji) {
const span = document.createElement("span");
span.innerText = "Wanikani doesn't have this kanji! :(";
container.append(span);
return container;
}
const onyomis = kanji.readings.filter(
item => item.type.toLocaleLowerCase() === "onyomi"
);
const kunyomis = kanji.readings.filter(
item => item.type.toLocaleLowerCase() === "kunyomi"
);
const onyomi = stringfyArray(onyomis, item => item.reading);
const kunyomi = stringfyArray(kunyomis, item => item.reading);
const meaning = stringfyArray(kanji.meanings, item => item.meaning);
container.append(generateInfo("LV", kanji.level));
container.append(generateInfo("EN", meaning));
if (onyomi !== "None" && onyomi !== "")
container.append(generateInfo("ON", onyomi));
if (kunyomi !== "None" && kunyomi !== "")
container.append(generateInfo("KN", kunyomi));
container.append(generateInfo("SRS", stringfySrs(kanji.srs)));
if (state.jiff) {
container.append(generateInfo("JOYO", kanji.joyo));
container.append(generateInfo("JLPT", kanji.jlpt));
container.append(generateInfo("FREQ", kanji.frequency));
}
return container;
}
function stringfyArray(array, pathToString) {
let stringfied = "";
array.forEach(item => {
stringfied = stringfied.concat(pathToString(item) + ", ");
});
stringfied = stringfied.substring(0, stringfied.length - 2);
return stringfied;
}
function stringfySrs(srs) {
switch (srs) {
case -1:
return "Locked";
case 0:
return "Ready To Learn";
case 1:
return "Apprentice 1";
case 2:
return "Apprentice 2";
case 3:
return "Apprentice 3";
case 4:
return "Apprentice 4";
case 5:
return "Guru 1";
case 6:
return "Guru 2";
case 7:
return "Master";
case 8:
return "Enlightened";
case 9:
return "Burned";
default:
return "";
}
}
function generateInfo(title, info) {
const container = document.createElement("div");
const key = document.createElement("span");
key.setAttribute("class", "acs-tooltip-header");
const value = document.createElement("span");
key.innerText = title;
value.innerText = info;
container.append(key, " ", value);
return container;
}
function getKanji() {
const filters = {
item_type: ["kan"]
};
if (wkof.get_state("wkof.Kumirei.JJFFilters") === "ready") {
state.jiff = true;
filters.include_frequency_data = true;
filters.include_jlpt_data = true;
filters.include_joyo_data = true;
} else {
console.warn(
`${scriptName}: You don't have Open Framework JLPT Joyo and Frequency Filters by @Kumirei installed (version 0.1.4 or later). Install the script if you want to get more information while hovering on Kanji on Context Sentences. Script URL: https://community.wanikani.com/t/userscript-open-framework-jlpt-joyo-and-frequency-filters/35096`
);
}
return wkof.ItemData.get_items({
wk_items: {
options: {
assignments: true
},
filters
}
});
}
function extractKanjiFromResponse(items) {
const kanjis = [];
items.forEach(item => {
const kanji = {
char: item.data.characters,
readings: item.data.readings,
level: item.data.level,
meanings: item.data.meanings,
url: item.data.document_url,
srs: item.assignments ? item.assignments.srs_stage : -1,
jlpt: item.jlpt_level,
joyo: item.joyo_grade,
frequency: item.frequency
};
kanjis.push(enhanceWithAditionalFilters(kanji, item));
});
state.kanjis = kanjis;
}
function enhanceWithAditionalFilters(kanji, item) {
if (state.jiff) {
kanji.jlpt = item.jlpt_level;
kanji.joyo = item.joyo_grade;
kanji.frequency = item.frequency;
}
return kanji;
}
function createSource(type, sentence) {
const source = document.createElement("source");
source.setAttribute("type", type);
source.setAttribute(
"src",
`https://translate.google.com/translate_tts?ie=UTF-8&client=tw-ob&tl=ja&total=1&idx=0&q=${sentence}`
);
return source;
}
function highlightKanji() {
const rules = document.querySelector("#acs-style").sheet.cssRules;
rules[0].style.color = state.settings.recognizedKanjiColor;
rules[1].style.color = state.settings.unrecognizedKanjiColor;
if (!wkof) return;
const anchors = document.querySelectorAll(".context-sentence-group a");
anchors.forEach(anchor => {
const srs = anchor.getAttribute("data-srs");
const char = anchor.getAttribute("data-kanji");
if (srs >= state.settings.recognitionLevel)
anchor.setAttribute("class", "recognized");
else {
anchor.setAttribute("class", "unrecognized");
}
if (anchor._tippy) anchor._tippy.destroy();
if (state.settings.tooltip.show) {
const kanji = state.kanjis.find(item => item.char == char);
const tooltip = createTooltip(kanji);
tippy(anchor, {
content: tooltip.outerHTML,
size: "small",
arrow: true,
placement: state.settings.tooltip.position,
delay: [state.settings.tooltip.delay, 20]
});
}
});
}
// Neccessary in order for audio to work
function createReferrer() {
const remRef = document.createElement("meta");
remRef.name = "referrer";
remRef.content = "no-referrer";
document.querySelector("head").append(remRef);
}
// Styles
function createStyle() {
const style = document.createElement("style");
style.setAttribute("id", "acs-style");
style.innerHTML = `
/* Kanji */
/* It's important for this one to be the first rule*/
${recognizedSelector} {
}
/* It's important for this one to be the second rule*/
${unrecognizedSelector} {
}
.context-sentence-group p a {
text-decoration: none;
}
.context-sentence-group p a:hover {
text-decoration: none;
}
.acs-tooltip {
text-align: left
}
.acs-tooltip-header {
color: #929292
}
`;
document.querySelector("head").append(style);
}
})();