"use strict";
// ==UserScript==
// @name Advanced Context Sentence
// @namespace https://openuserjs.org/users/abdullahalt
// @version 1.44
// @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/*
// @match https://preview.wanikani.com/lesson/session
// @match https://preview.wanikani.com/review/session
// @match https://preview.wanikani.com/vocabulary/*
// @require https://unpkg.com/@popperjs/[email protected]/dist/umd/popper.min.js
// @require https://unpkg.com/[email protected]/dist/tippy-bundle.umd.min.js
// @require https://greasyfork.org/scripts/430565-wanikani-item-info-injector/code/WaniKani%20Item%20Info%20Injector.user.js?version=975978
// @supportURL https://community.wanikani.com/t/35079
// @grant none
// @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 recognizedSelector = "a.recognized";
const unrecognizedSelector = "a.unrecognized";
let state = {
settings: {
recognizedKanjiColor: "#f100a1",
unrecognizedKanjiColor: "#888888",
recognitionLevel: "5",
tooltip: {
show: true,
delay: 0,
position: "top"
},
voice: "browser"
},
kanjis: [],
jiff: false // JLPT, Joyo and Frequency Filters
};
// Application start Point
main();
function main() {
init(() => wkItemInfo.forType(`vocabulary`).under(`examples`).notify(() => evolveContextSentence(sentences => sentences[0].previousElementSibling)));
}
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 dialog for the script. You can still use Advanced Context Sentence normally though`
);
callback();
}
}
function evolveContextSentence(getHeader) {
const sentences = document.querySelectorAll(".context-sentence-group:not(.advanced)");
sentences.forEach(s => s.classList.add("advanced"));
if (sentences.length === 0) return;
if (wkof) evolveHeader(getHeader(sentences));
sentences.forEach(sentence => {
const japaneseSentence = sentence.querySelector('p[lang="ja"]');
const sentenceText = japaneseSentence.textContent;
const audioButton = createAudioButton(sentenceText);
const chars = Array.from(sentenceText);
const newNodes = chars.map(char => tagAndLinkKanji(char));
japaneseSentence.replaceChildren(...newNodes);
japaneseSentence.append(audioButton);
});
highlightKanji();
}
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);
}
function recreateAudioButtons() {
document.querySelectorAll(`.context-sentence-group > p > span:last-child, .context-sentence-group > p > button:last-child`).forEach(audioButton => audioButton.remove());
const sentences = document.querySelectorAll(".context-sentence-group");
sentences.forEach(sentence => {
const japaneseSentence = sentence.querySelector('p[lang="ja"]');
const sentenceText = japaneseSentence.textContent;
const audioButton = createAudioButton(sentenceText);
japaneseSentence.append(audioButton);
});
}
function createAudioButton(sentence) {
if (state.settings.voice === "google") {
return createAudioButtonGoogleTL(sentence);
} else {
return createAudioButtonSpeechSynthesis(sentence);
}
}
/**
* 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 createAudioButtonGoogleTL(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 createAudioButtonSpeechSynthesis(sentence) {
if (!window.SpeechSynthesisUtterance) {
console.warn("Advanced Context Sentence: your browser does not support SpeechSynthesisUtterance which this script utilizes to implement the audio feature. update your browser or use another one if you want that feature");
return null;
}
const button = document.createElement("button");
button.setAttribute("class", "audio-btn audio-idle");
button.onclick = () => {
var msg = new SpeechSynthesisUtterance(sentence);
msg.lang = "ja-JP";
window.speechSynthesis.speak(msg);
msg.onstart = () => {
button.setAttribute("class", "audio-btn audio-play");
};
msg.onend = () => {
button.setAttribute("class", "audio-btn audio-idle");
};
};
return button;
}
//--------------------------------------------------------------------------------------------------------------//
//----------------------------------------------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"
}
},
voiceSection: {
type: "section",
label: "Voice"
},
voice: {
type: "dropdown",
label: "Voice",
hover_tip: "Select the machine voice that reads the sentence aloud",
default: state.settings.voice,
content: {
browser: "Web Browser",
google: "Google Translate"
}
}
}
};
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();
recreateAudioButtons();
}
//---------------------------------------------------------------------------------------------------------------//
//-------------------------------------------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) : document.createTextNode(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("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=${encodeURIComponent(sentence)}`
);
return source;
}
let tippys = new Set();
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;
tippys.forEach(t => t.destroy());
tippys = new Set();
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 (state.settings.tooltip.show) {
const kanji = state.kanjis.find(item => item.char == char);
const tooltip = createTooltip(kanji);
tippy(anchor, {
content: tooltip,
size: "small",
arrow: true,
placement: state.settings.tooltip.position,
delay: [state.settings.tooltip.delay, 20]
});
tippys.add(anchor._tippy);
}
});
}
// 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);
}
})();