Compatibility with upcoming changes to lessons that use React components.
// ==UserScript==
// @name WaniKani Lesson User Synonyms 2 Fork
// @namespace est_fills_cando
// @version 0.3.11
// @description Allows adding synonyms during lesson and lesson quiz.
// @author est_fills_cando
// @match https://www.wanikani.com/lesson/session
// @match https://preview.wanikani.com/lesson/session
// @grant none
// @run-at document-end
// ==/UserScript==/
/*global $, wkof */
// based heavily on the WaniKani Lesson User Synonyms 2 userscript
(function () {
// Make sure WKOF is installed
if (!wkof) {
let response = confirm(script_name+' requires WaniKani Open Framework.\n Click "OK" to be forwarded to installation instructions.');
if (response) {
window.location.href = 'https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549';
}
return;
}
wkof.include('Apiv2');
let UserSynonyms2 = {};
let keyTTL = 180 * 24 * 3600 * 1000; // 180 days should be enough for anyone
// subject ids are unique even across different subject types
// the subject type is not required when updating the synonym list via the api
// therefore, we can ignore subject types
function getSynKeyItem(item) {
return getSynKeyID(item.id);
}
function getSynKeyID(id) {
return 'l/syn/' + id;
}
function getSynListItem(item) {
return getSynListID(item.id);
}
function getSynListID(id) {
let synKey = getSynKeyID(id);
$.jStorage.setTTL(synKey, keyTTL); // extend TTL on read. setting TTL on non-existant keys seems fine.
let retVal = $.jStorage.get(synKey) || [];
return retVal;
}
function setSynListID(id, synList) {
let synKey = getSynKeyID(id);
if (synList.length > 0) {
$.jStorage.set(synKey, synList);
$.jStorage.setTTL(synKey, keyTTL);
} else {
$.jStorage.deleteKey(synKey);
}
}
// Calls callback once on each element matching selector that is added
// to the DOM, including existing elements at the time the function is first called.
// options is options for the MutationObserver (optional, is merged with default options {childList: true, subtree: true})
function onEachElementReady(selector, options, callback) {
if (!options)
options = {};
// optimizations for certain commonly used selector patterns
let els_found = new WeakSet()
function check_available() {
for (const el of document.querySelectorAll(selector)) {
if (!els_found.has(el)) {
els_found.add(el);
callback(el);
}
}
}
queueMicrotask(check_available);
let mo = new MutationObserver(check_available);
mo.observe(document.documentElement, {
childList: true,
subtree: true,
...options
});
}
// runs the callback every time attributes of elt change
// including once initially
// this function only works if elt exists at the time it is called
function onEachAttrChange(elt, options, callback) {
if (!options)
options = {}
options = {attributes: true, ...options};
queueMicrotask(callback);
let mo = new MutationObserver(callback);
mo.observe(elt, options);
return mo;
}
// calls callback each time jStorage value stored at key,
// is updated with the updated value,
// including cases where the value is overwritten with itself,
// including once for the initial value when this function is executed
// options is an object or unset
// options.callOnNull is whether to call on null values (default:false)
// options.priority is one of 'microtask', 'synchronous' (default: 'microtask')
// and specifies how callback should be called.
// Changes triggered by other windows or that bypass jstorage by manipulating
// localStorage directly will always be called as a macrotask if priority is set
// to synchronous or as a macrotask that then triggers a microtask if priroity is
// set to microtask.
// options.noRecursion (default:false) if true, an event handler that triggers itself while handling an
// event will not cause a subsequent call to itself (other event handlers
// may still be triggered)
function onEachJStorageChange(key, callback, options) {
if (!options)
options = {};
if (!options.priority)
options.priority = 'microtask';
if (!['microtask', 'synchronous'].includes(options.priority))
throw 'invalid value for options.priority';
const callOnNull = options.callOnNull;
let in_call = false;
callback = (function(callback) {return function() {
in_call = true;
let val = $.jStorage.get(key);
try {
if (val !== null || callOnNull)
callback(val);
} finally {
in_call = false;
}
}})(callback);
if (options.priority == 'microtask')
callback = (function(callback) {return function() {
queueMicrotask(callback)
}})(callback);
if (options.noRecursion)
callback = (function(callback) {return function() {
if (!in_call)
callback();
}})(callback);
$.jStorage.listenKeyChange(key, function (key, action) {
if (action === 'updated') {
callback();
}
});
callback(); // jStorage event handlers are called synchronously
}
// loads any user synonyms stored on WaniKani's servers into the locally stored lists of synonyms,
// clobbering any existing values
// also stores subject ids of each subject
// input is array of subject_ids to retrieve
UserSynonyms2.loadSynonymsFromAPI = async function (ids) {
if (ids.length == 0)
return new Promise(resolve => resolve([]));
let options = {
force_update: true,
filters: {
subject_ids: ids
}
};
await wkof.ready('Apiv2');
let mats = await wkof.Apiv2.fetch_endpoint('study_materials', options);
let syns = {};
for (const mat of mats.data) {
syns[mat.data.subject_id] = mat.data.meaning_synonyms;
}
for (const id of ids) {
let syns_for_id = [];
if (id in syns) {
syns_for_id = syns[id];
}
setSynListID(id, syns_for_id);
}
}
// removes the given synonym, if it exists, from the given item in jStorage
// and saves the synonym list
UserSynonyms2.removeAndSave = function (itemID, syn) {
let li = getSynListID(itemID).filter(entry => entry != syn);
return UserSynonyms2.saveSynonymList(itemID, li);
}
// adds the given synonym to the given item in jStorage
// and saves the synonym list
UserSynonyms2.addAndSave = function (itemID, syn) {
let li = getSynListID(itemID);
li.push(syn);
return UserSynonyms2.saveSynonymList(itemID, li);
}
// prevent enter key up, which happens after textbox disappears
// and therefore triggers on body, from advancing lesson/quiz
let enterKeypressStartedInInput = false;
function setKeyInterceptor(selector) {
$(selector).keydown(function (event) {
if (event.key == "Enter" && enterKeypressStartedInInput) {
event.stopPropagation();
}
});
$(selector).keyup(function (event) {
if (event.key == "Enter" && enterKeypressStartedInInput) {
event.stopPropagation();
enterKeypressStartedInInput = false;
}
});
}
UserSynonyms2.load = function (userSynStr, itemID, element) {
if (!element || element.length == 0)
return
UserSynonyms2.generateList(element, userSynStr);
UserSynonyms2.addOption(itemID);
UserSynonyms2.removeOption(itemID);
// recalculate positioning of navigation bar at bottom of lesson page
// only seems to be needed on initial page load when information section
// is long enough that it nearly reaches the bottom of the page
// do it in request animation frame instead of setttimeout
// to prevent the nav bar visually "jumping" from wrong spot to right spot
// don't use microtasks because the values don't seem to have perfectly
// stabilized at that point
// Doesn't seem to be needed after react slides update
if (window.WaniKani.features.hasOwnProperty('reactSlides') && !window.WaniKani.features.reactSlides) {
window.requestAnimationFrame(function() {
var e= $("#supplement-info");
var t=$("#batch-items");
if (t.length > 0) {
if($(window).height() - e.offset().top - e.outerHeight(!0) > t.height() + 25) {
t.get(0).classList.add("bottom");
} else {
t.get(0).classList.remove("bottom");
}
}
});
}
};
UserSynonyms2.addOption = function (itemID) {
let btnAdd, wrapper;
btnAdd = $('.user-synonyms-add-btn');
wrapper = UserSynonyms2.wrapper();
btnAdd.off('click');
btnAdd.on('click', function () {
let inputBtnRemove, inputBtnAdd, inputLi, inputForm, inputInput;
$(this).hide();
inputLi = $('<li></li>', {
'class': 'user-synonyms-add-form'
}).appendTo(wrapper);
inputForm = $('<form></form>').appendTo(inputLi);
inputInput = $('<input></input>', {
type: 'text',
autocapitalize: 'off',
autocomplete: 'off',
autocorrect: 'off'
}).appendTo(inputForm).focus();
inputInput.keyup(function (event) {
event.stopPropagation();
if (event.key == "Enter") {
enterKeypressStartedInInput = false;
}
}); // prevent hotkeys while typing
inputInput.keydown(function (event) {
event.stopPropagation();
if (event.key == "Enter") {
event.preventDefault(); // not sure if necessary
enterKeypressStartedInInput = true;
inputBtnAdd.click();
}
}); // prevent hotkeys while typing
inputBtnAdd = $('<button></button>', {
type: 'submit',
text: 'Add'
}).appendTo(inputForm);
inputBtnRemove = $('<button></button>', {
type: 'button',
html: '<i class="icon-remove"></i>'
}).appendTo(inputForm);
inputBtnAdd.off('click');
inputBtnAdd.on('click', function (event) {
event.preventDefault();
let newSynText, synLiElems, newLen;
newSynText = inputInput.val();
// fake accept duplicate, but don't send to server (server doesn't check?)
if (getSynListID(itemID).indexOf(newSynText.toLowerCase()) >= 0) {
// delay for key events to be caught
setTimeout(function () {
inputLi.remove();
btnAdd.show();
}, 100);
return;
}
synLiElems = wrapper.find('li');
newLen = newSynText.length + synLiElems.slice(0, synLiElems.size() - 1).text().length;
if (newLen > 255) {
inputBtnAdd.attr('disabled', 'disabled').text('Exceeded Synonym Limit');
} else if (newSynText.trim().length !== 0) {
UserSynonyms2.addAndSave(itemID, newSynText).then(function() {
let newSynElem;
newSynElem = $('<li class="user-synonym">' + newSynText + '</li>');
wrapper.find('li:last').prev().before(newSynElem);
inputLi.remove();
btnAdd.show();
UserSynonyms2.removeOption(itemID);
}).catch(function() {
alert('adding synonym failed');
});
}
});
inputBtnRemove.off('click');
inputBtnRemove.on('click', function (event) {
inputLi.remove();
btnAdd.show();
});
});
};
/**
* Save the list of user synonms currently in local storage
* Only works if subject with itemID is in the activeQueue
* @param itemID
*/
UserSynonyms2.saveSynonymList = async function (itemID, synList) {
function getType(item) {
var type = 'x';
if (item.rad) {
type = 'radical';
} else if (item.kan) {
type = 'kanji';
} else if (item.voc) {
type = 'vocabulary';
}
return type;
}
let its = $.jStorage.get('l/activeQueue').filter(it => it.id == itemID);
let it;
if (its.length > 0)
it = its[0];
else
it = $.jStorage.get('l/currentQuizItem')
let type = getType(it);
//let url = "https://api.wanikani.com/v2/study_materials/" + itemID;
let url = "/study_materials/" + itemID;
await $.ajax({
type: 'PUT',
url: url,
contentType: 'application/json; charset=utf-8',
data: JSON.stringify({
study_material: {
subject_type: type,
subject_id: itemID,
meaning_synonyms: synList
}
})
});
setSynListID(itemID, synList);
}
UserSynonyms2.generateList = function (element, userSyn) {
let wrapper, i;
$('.user-synonyms ul').remove();
element.append($('<ul></ul>'));
wrapper = UserSynonyms2.wrapper();
for (i = 0; i < userSyn.length; i++) {
$('<li class="user-synonym">' + userSyn[i] + '</li>', {
title: 'Click to remove synonym'
}).appendTo(wrapper);
}
$('<li></li>', {
html: ' ',
title: 'Add your own synonym',
'class': 'user-synonyms-add-btn'
}).appendTo(wrapper);
};
UserSynonyms2.removeOption = function (itemID) {
let synElems = UserSynonyms2.wrapper().find('li:not(.user-synonyms-add-btn):not(.user-synonyms-add-form)');
synElems.off('click');
synElems.on('click', function () {
let clickedSynElem = $(this);
UserSynonyms2.removeAndSave(itemID, clickedSynElem.text()).then(clickedSynElem.remove.bind(clickedSynElem));
});
};
UserSynonyms2.wrapper = function () {
return $('.user-synonyms ul');
};
// from: https://gist.githubusercontent.com/arantius/3123124/raw/grant-none-shim.js
function addStyle(aCss) {
let head, style;
head = document.getElementsByTagName('head')[0];
if (head) {
style = document.createElement('style');
style.setAttribute('type', 'text/css');
style.textContent = aCss;
head.appendChild(style);
return style;
}
return null;
}
// Init / UI Display
let initializedSkeletons = new Map();
UserSynonyms2.synonymSection = function(child_or_sibling, target) {
let header, body;
if (initializedSkeletons.has(target.get(0))) {
[header, body] = initializedSkeletons.get(target.get(0));
//return body;
} else {
header = $('<h2>User Synonyms</h2>');
body = $('<section class="user-synonyms"></section>');
}
/*for (let [h, b] of initializedSkeletons.values()) {
if (h!==header)
h.remove();
if (b!==body)
b.remove();
}*/
if (child_or_sibling =='sibling')
header.insertAfter(target);
else if (child_or_sibling == 'child')
header.appendTo(target);
body.insertAfter(header);
initializedSkeletons.set(target.get(0), [header, body]);
return body;
}
let quizVisibilityObserver = null;
UserSynonyms2.showCurrentSynonyms = function() {
if (quizVisibilityObserver) {
quizVisibilityObserver.disconnect();
quizVisibilityObserver = null;
}
if ($.jStorage.get('l/quizActive')) {
let el, synSec;
let currentQuizItem = $.jStorage.get('l/currentQuizItem');
if (!currentQuizItem || !initialized_subject_ids.has(currentQuizItem.id)) {return}
if (currentQuizItem.rad) {
el = $('#item-info-col1 section:first-child');
if (el.length==0) {return}
synSec = UserSynonyms2.synonymSection('child', el);
} else {
el = $('#item-info-meaning');
if (el.length==0) {return}
synSec = UserSynonyms2.synonymSection('sibling', el);
quizVisibilityObserver = onEachAttrChange(el.get(0), null, function() {
if (window.getComputedStyle(el.get(0)).display != 'none' || $.jStorage.get('l/questionType') == 'meaning') {
synSec.css('display','');
synSec.prev().css('display','');
} else {
synSec.css('display', 'none');
synSec.prev().css('display', 'none');
}
});
}
el.contents().filter(function() {return this.nodeType==Node.TEXT_NODE}).get(0).textContent = currentQuizItem.original_answers_en.join(', ');
UserSynonyms2.load(getSynListItem(currentQuizItem), currentQuizItem.id, synSec);
} else {
let currentLesson = $.jStorage.get('l/currentLesson');
if (!currentLesson || !initialized_subject_ids.has(currentLesson.id)) {return}
let synSec;
if (currentLesson.rad) {
synSec = UserSynonyms2.synonymSection('sibling', $('#supplement-rad-name-mne'));
UserSynonyms2.load(getSynListItem(currentLesson), currentLesson.id, synSec);
} else if (currentLesson.kan) {
synSec = UserSynonyms2.synonymSection('sibling', $('#supplement-kan-meaning .col1 div'));
UserSynonyms2.load(getSynListItem(currentLesson), currentLesson.id, synSec);
} else if (currentLesson.voc) {
synSec = UserSynonyms2.synonymSection('sibling', $('#supplement-voc-synonyms'));
UserSynonyms2.load(getSynListItem(currentLesson), currentLesson.id, synSec);
}
synSec.show();
}
}
// during lesson
// prevent enter events during synonym entry from triggering unrelated things
setKeyInterceptor('body');
// at start of each lesson set (detected via activeQueue change)
// load synonym data from api
let initialized_subject_ids = new Set();
onEachJStorageChange('l/activeQueue', function(activeQueue) {
let subject_ids = activeQueue.map(it => it.id).filter(id => !initialized_subject_ids.has(id));
subject_ids.forEach(id => initialized_subject_ids.add(id));
if (subject_ids.length != 0) {
UserSynonyms2.loadSynonymsFromAPI(subject_ids).then(function() {
UserSynonyms2.showCurrentSynonyms();
});
}
});
// show user synonyms when looking at each lesson
onEachJStorageChange('l/currentLesson', function (currentLesson) {
UserSynonyms2.showCurrentSynonyms();
});
// during lesson quiz
// copy user synonyms into the answers list for quiz
onEachJStorageChange('l/currentQuizItem', function (currentQuizItem) {
currentQuizItem = {...currentQuizItem}; // object we get is frozen for some reason so copy to new object to unfreeze
if (!currentQuizItem.original_answers_en)
currentQuizItem.original_answers_en = [...currentQuizItem.en];
currentQuizItem.en = currentQuizItem.original_answers_en.concat(getSynListItem(currentQuizItem));
$.jStorage.set('l/currentQuizItem', currentQuizItem); // this call would cause infinite recursion without checking for change
}, {noRecursion: true});
// show user synonyms in information box in lesson quiz for radicals
onEachElementReady('#item-info-col1 section:first-child', null, function(el) {
if ($(el).find('h2').text() == 'Name')
UserSynonyms2.showCurrentSynonyms();
});
// show user synonyms in information box in lesson quiz for kanji and vocab
// and lesson for all
onEachElementReady('#item-info-meaning, #supplement-rad-name-mne, #supplement-kan-meaning .col1, #supplement-voc-synonyms', null, function(el) {
UserSynonyms2.showCurrentSynonyms();
});
// remove jstorage entries for completed items
onEachJStorageChange('l/activeQueue', function (queue) {
let activeIds = new Set(queue.map((it) => it.id));
for (const id of new Set(initialized_subject_ids)) {
if (!activeIds.has(id)) {
let synKey = getSynKeyID(id);
$.jStorage.deleteKey(synKey);
initialized_subject_ids.delete(id);
}
}
});
// this is style extracted from the wanikani review page
addStyle('\n' +
'.user-synonyms ul {\n' +
' margin: 0;\n' +
' padding: 0;\n' +
'}\n' +
'.user-synonyms ul li {\n' +
' display: inline-block;\n' +
' line-height: 1.5em;\n' +
'}\n' +
'.user-synonyms ul li:not(.user-synonyms-add-btn):not(.user-synonyms-add-form) {\n' +
' cursor: pointer;\n' +
' vertical-align: middle;\n' +
'}\n' +
'.user-synonyms ul li:not(.user-synonyms-add-btn):not(.user-synonyms-add-form):after {\n' +
' background-color: #EEEEEE;\n' +
' border-radius: 3px;\n' +
' color: #A2A2A2;\n' +
' content: "\\f00d";\n' +
' font-family: FontAwesome;\n' +
' font-size: 0.5em;\n' +
' margin-left: 0.5em;\n' +
' margin-right: 1.5em;\n' +
' padding: 0.15em 0.3em;\n' +
' transition: background-color 0.3s linear 0s, color 0.3s linear 0s;\n' +
' vertical-align: middle;\n' +
'}\n' +
'.user-synonyms ul li:hover:not(.user-synonyms-add-btn):not(.user-synonyms-add-form):after {\n' +
' background-color: #FF0033;\n' +
' color: #FFFFFF;\n' +
'}\n' +
'.user-synonyms ul li.user-synonyms-add-btn {\n' +
' cursor: pointer;\n' +
' display: block;\n' +
' font-size: 0.75em;\n' +
' margin-top: 0.25em;\n' +
'}\n' +
'.user-synonyms ul li.user-synonyms-add-btn:after {\n' +
' content: "";\n' +
'}\n' +
'.user-synonyms ul li.user-synonyms-add-btn:before {\n' +
' background-color: #EEEEEE;\n' +
' border-radius: 3px;\n' +
' color: #A2A2A2;\n' +
' content: "+ ADD SYNONYM";\n' +
' margin-right: 0.5em;\n' +
' padding: 0.15em 0.3em;\n' +
' transition: background-color 0.3s linear 0s, color 0.3s linear 0s;\n' +
'}\n' +
'.user-synonyms ul li.user-synonyms-add-btn:hover:before {\n' +
' background-color: #A2A2A2;\n' +
' color: #FFFFFF;\n' +
'}\n' +
'.user-synonyms ul li.user-synonyms-add-form {\n' +
' display: block;\n' +
'}\n' +
'.user-synonyms ul li.user-synonyms-add-form form {\n' +
' display: block;\n' +
' margin: 0;\n' +
' padding: 0;\n' +
'}\n' +
'.user-synonyms ul li.user-synonyms-add-form form input, .user-synonyms ul li.user-synonyms-add-form form button {\n' +
' line-height: 1em;\n' +
'}\n' +
'.user-synonyms ul li.user-synonyms-add-form form input {\n' +
' -moz-border-bottom-colors: none;\n' +
' -moz-border-left-colors: none;\n' +
' -moz-border-right-colors: none;\n' +
' -moz-border-top-colors: none;\n' +
' border-color: -moz-use-text-color -moz-use-text-color #A2A2A2;\n' +
' border-image: none;\n' +
' border-style: none none solid;\n' +
' border-width: 0 0 1px;\n' +
' display: block;\n' +
' margin: 0;\n' +
' outline: medium none;\n' +
' padding: 0;\n' +
' width: 100%;\n' +
'}\n' +
'.user-synonyms ul li.user-synonyms-add-form form button {\n' +
' background-color: #EEEEEE;\n' +
' border: medium none;\n' +
' border-radius: 3px;\n' +
' color: #A2A2A2;\n' +
' font-size: 0.75em;\n' +
' outline: medium none;\n' +
' transition: background-color 0.3s linear 0s, color 0.3s linear 0s;\n' +
'}\n' +
'.user-synonyms ul li.user-synonyms-add-form form button:hover {\n' +
' background-color: #A2A2A2;\n' +
' color: #FFFFFF;\n' +
'}\n' +
'.user-synonyms ul li.user-synonyms-add-form form button:disabled {\n' +
' background-color: #FF0000;\n' +
' color: #FFFFFF;\n' +
' cursor: default;\n' +
'}\n' +
'.user-synonyms ul li.user-synonyms-add-form form button[type="button"] {\n' +
' margin-left: 0.25em;\n' +
' padding-left: 0.3em;\n' +
' padding-right: 0.3em;\n' +
'}\n' +
'.user-synonyms ul li.user-synonyms-add-form form button[type="button"]:hover {\n' +
' background-color: #FF0000;\n' +
' color: #FFFFFF;\n' +
'}\n');
})();