[Userscript] WaniKani Lesson User Synonyms v2

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: '&nbsp;',
            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');
})();
5 Likes