[Userscript] WaniKani Lesson User Synonyms v2

Hypothetically yes but it hasn’t shown up during the lesson quiz for months. I don’t usually add synonyms during the quiz, and haven’t heard many complaints, so I probably will only try to fix the issue during the lesson itself before the quiz.

1 Like

Troubleshooting the problem: For me it seems to happen every single time I go between different sets of lessons - so only after I press “Continue with lew lessons” is the “+ADD SYNONYM” button not there (when I go to the Meaning tab).

After I’ve refreshed the lessons page once, I can spam-refresh the page without the button ever disappearing - so it truly only ever seems to happen after “Continue with new lessons”.

It never seems to happen when I go to do lessons from the front page, i.e. through this page:

Here is a completely untested change that might fix the issue. I don’t have any lessons right now, so I haven’t tested it all. This version might not even work at all.

// ==UserScript==
// @name          WaniKani Lesson User Synonyms 2 Fork
// @namespace     est_fills_cando
// @version       0.3.7
// @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
        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));
        } else {
            header = $('<h2>User Synonyms</h2>');
            body = $('<section class="user-synonyms"></section>');
        }

        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.get(0);
    }

    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') {
                        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}
            if (currentLesson.rad) {
                let synSec = UserSynonyms2.synonymSection('sibling', $('#supplement-rad-name-mne'));
                UserSynonyms2.load(getSynListItem(currentLesson), currentLesson.id, synSec);
            } else if (currentLesson.kan) {
                let synSec = UserSynonyms2.synonymSection('sibling', $('#supplement-kan-meaning .col1 div'));
                UserSynonyms2.load(getSynListItem(currentLesson), currentLesson.id, synSec);
            } else if (currentLesson.voc) {
                let synSec = UserSynonyms2.synonymSection('sibling', $('#supplement-voc-synonyms'));
                UserSynonyms2.load(getSynListItem(currentLesson), currentLesson.id, synSec);
            }
        }
    }

    // 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
    onEachElementReady('#item-info-meaning', null, function(el) {
        UserSynonyms2.showCurrentSynonyms();
    });

    // remove jstorage entries for completed items to prevent disk space usage leak
    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');
})();


I give this version a 10/10 rating since it was unexpected and made me laugh!

Edit: Somehow it’s like this now, did the copypasting of the script all over again in case I somehow accidentally removed something (and I’ve ofc disabled the previous versions):

image

1 Like

Sorry about that. I guess I’ll just need to wait until I actually have lessons to figure out what is going wrong.

1 Like

I think this will work

// ==UserScript==
// @name          WaniKani Lesson User Synonyms 2 Fork
// @namespace     est_fills_cando
// @version       0.3.8
// @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
        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') {
                        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
    onEachElementReady('#item-info-meaning', null, function(el) {
        UserSynonyms2.showCurrentSynonyms();
    });

    // remove jstorage entries for completed items to prevent disk space usage leak
    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');
})();
1 Like

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

Hi!

As wanikani lesson user synonyms v2 stopped working for me now I assume the change to React have gone through.

How do I install your script can i find it on Greasyfork? Is there some other way I should do?

I do not know a lot about programming and am very grateful for anyone taking there time to fix this!

A big thank you!

Are you using Tampermonkey? If yes, then the guide in this other thread should be helpful (specifically section 3). You just have to replace the entire code with the code that @est_fills_cando posted.

Thank you Sinyaven! reallt appreciate it : :smiley:

Hi again!

Tl:dr followed the guide the script does not work for me.

I am using tampermonkey and have followed the guide.
Replaced the code from the old version with the code from @est_fills_cando

  1. the script is enabled
  2. I can not find an apikey here that is not already part of the code

I do not run any other scripts for wanikani. Only this script and the basic wanikani functions.
I’ve attached a screenshot to show what it looks like.
The things I’ve tried are restarting my browser and restarting the computer and repasted the new code.

I would highly appreciate any guidance that could be provided.

Oh, I did not realize that @est_fills_cando’s version additionally requires the WaniKani Open Framework. You can install that one from here:

1 Like

Sinyaven, it is now working.

I’m really grateful for your help and for @est_fills_cando for creating the script!!!

1 Like

Thanks man. Got another broken script working.

For some reason synonyms stopped working on some radicals, even tho they were available on level 1 and 2 for me. Script still works just fine on kanji and vocabulary.