[Userscript] WaniKani Lesson User Synonyms v2

HI there,
Back to WK after a long, long pause. Had to clean all my former installed scripts
As a non-english native, I previously installed WaniKani Lesson User Synonyms v2 and it was a great help for me to be able to immediatly translate words from english to french. Thanks a lot for that !

So I re-installed a lot a scripts as I did three years ago, It seems they all run as expected, except WK Lesson user Synonyms v2: no ‘User Synonyms/+ADD SYNONYM’ zone appear during lessons. As I reseted to level 2, my previous Level-1 words are still there, but I can’t add new during lessons. I tried to check in ViolentMonkey (I use it) but everything seems normal.

Any idea about this issue ? Thank you all.

Were you trying to use it during the learning part of the lesson or the lesson quiz? The script doesn’t add it to the quiz part. I have a fork I’ve been using that does this and has better handling of a few corner cases, not fully tested tho:

// ==UserScript==
// @name          WaniKani Lesson User Synonyms 2 Fork
// @namespace     est_fills_cando
// @version       0.3.1
// @description   Allows adding synonyms during lesson and lesson quiz.
// @author        est_fills_cando
// @match         https://www.wanikani.com/lesson/session
// @grant         none
// ==/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 = {};

        let els_found = new Set()
        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 initially
    // callOnNull is whether to call on null values (default:false)
    function onEachJStorageChange(key, callback, callOnNull) {
        let val = $.jStorage.get(key);
        if (val !== null || callOnNull)
            setTimeout(() => callback(val), 0); //setTimeout here because local storage events are macro tasks
        $.jStorage.listenKeyChange(key, function (key, action) {
            if (action === 'updated') {
                val = $.jStorage.get(key);
                if (val !== null || callOnNull)
                    callback(val);
            }
        });
    }


    // 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) {
        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 = "https://www.wanikani.com/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

    UserSynonyms2.synonymSection = function(insert_or_append, target) {
        let cur;
        if (insert_or_append =='insert')
            cur =$('<h2>User Synonyms</h2>').insertAfter(target);
        else if (insert_or_append == 'append')
            cur =$('<h2>User Synonyms</h2>').appendTo(target);
        cur = $('<section class="user-synonyms"></section>').insertAfter(cur);
        return cur;
    }

    let quizVisibilityObserver = null;
    UserSynonyms2.showCurrentSynonyms = function() {
        if (quizVisibilityObserver) {
            quizVisibilityObserver.disconnect();
            quizVisibilityObserver = null;
        }
        if ($.jStorage.get('l/quizActive')) {
            let currentQuizItem = $.jStorage.get('l/currentQuizItem');
            if (!currentQuizItem || !initialized_subject_ids.has(currentQuizItem.id)) {return}
            if (currentQuizItem.rad) {
                let el = $('#item-info-col1 section');
                if (!el) {return}
                let synSec = UserSynonyms2.synonymSection('append', el);
                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 el = $('#item-info-meaning');
                if (!el) {return}
                let synSec = UserSynonyms2.synonymSection('insert', el);
                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);
                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');
                    }
                });
            }
        } else {
            let currentLesson = $.jStorage.get('l/currentLesson');
            if (!currentLesson || !initialized_subject_ids.has(currentLesson.id)) {return}
            if (currentLesson.rad) {
                UserSynonyms2.load(getSynListItem(currentLesson), currentLesson.id, lrSynSec);
            } else if (currentLesson.kan) {
                UserSynonyms2.load(getSynListItem(currentLesson), currentLesson.id, lkSynSec);
            } else if (currentLesson.voc) {
                UserSynonyms2.load(getSynListItem(currentLesson), currentLesson.id, lvSynSec);
            }
        }
    }

    // during lesson

    // prevent enter events during synonym entry from triggering unrelated things
    setKeyInterceptor('body');

    // add base display elements
    let lrSynSec = UserSynonyms2.synonymSection('insert', $('#supplement-rad-name-mne'));
    let lkSynSec = UserSynonyms2.synonymSection('insert', $('#supplement-kan-meaning .col1 div'));
    let lvSynSec = UserSynonyms2.synonymSection('insert', $('#supplement-voc-synonyms'));

    // 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
    let in_call = false;
    onEachJStorageChange('l/currentQuizItem', function (currentQuizItem) {
        if (!in_call) {
            in_call = true;
            if (!currentQuizItem.orignal_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 using in_call
            in_call = false;
        }
    });

    // 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();
    });

    // watch for completed items. completed items should be safe to delete local storage...
    (function () {
        let lastCompleted = 0;
        onEachJStorageChange('l/count/completed', function (completed) {
            let currentQuizItem, synKey;
            if (completed > lastCompleted) {
                currentQuizItem = $.jStorage.get('l/currentQuizItem');
                synKey = getSynKeyItem(currentQuizItem);
                $.jStorage.deleteKey(synKey); // harmless to delete non-existant keys
            }
            lastCompleted = completed;
        });
    }());


    // 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');
}());

If you can’t see anything during the learning part of the lesson, then maybe it conflicts with another script you have installed?

@irrelephant are you still maintaining this? I have a fork with some features/fixes I was hoping to upstream like adding synonyms during quiz part of lesson, showing an error if the ajax to add the synonym failed, not clobbering synonyms added prior to lesson start, less hacky / more robust way of preventing enter key from advancing lesson, etc.

Thank you for your answer and contribution.
I just tested and the “add synonym” doesn’t appear during the lesson itself (the first part, when you are discovering the kanjis or vocab, before the lesson quizz).
I will try your script and will tell you what happen.

[EDIT] OK, I copy/paste your script in place of the original, ViolentMonkey does display 'Wanikani Lessons User Synonyms 2 Fork" but nothing changes for me…

What browser and browser version are you using?

Oh, sorry, I forgot this basic thing. I’m using Violentmonkey v2.12.7 on a Firefox 68.0 on Linux.
Last time I used it (on novembre 2018), it runs perfectly.
I’m trying it now on Opera with the Chrome Extension Manager and I’ll tell you.

NOK, it doesn’t run on the last version of Opera

The following modified version works for me on Firefox 82 / ViolentMonkey. Isn’t Firefox 68 no longer supported by Mozilla?

// ==UserScript==
// @name          WaniKani Lesson User Synonyms 2 Fork
// @namespace     est_fills_cando
// @version       0.3.2
// @description   Allows adding synonyms during lesson and lesson quiz.
// @author        est_fills_cando
// @match         https://www.wanikani.com/lesson/session
// @grant         none
// ==/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 = {};

        let els_found = new Set()
        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 initially
    // callOnNull is whether to call on null values (default:false)
    function onEachJStorageChange(key, callback, callOnNull) {
        let val = $.jStorage.get(key);
        if (val !== null || callOnNull)
            setTimeout(() => callback(val), 0); //setTimeout here because local storage events are macro tasks
        $.jStorage.listenKeyChange(key, function (key, action) {
            if (action === 'updated') {
                val = $.jStorage.get(key);
                if (val !== null || callOnNull)
                    callback(val);
            }
        });
    }


    // 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 = "https://www.wanikani.com/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 Set();
    UserSynonyms2.synonymSection = function(insert_or_append, target) {
        if (initializedSkeletons.has(target.get(0)))
            return;
        initializedSkeletons.add(target.get(0));
        let cur;
        if (insert_or_append =='insert')
            cur =$('<h2>User Synonyms</h2>').insertAfter(target);
        else if (insert_or_append == 'append')
            cur =$('<h2>User Synonyms</h2>').appendTo(target);
        cur = $('<section class="user-synonyms"></section>').insertAfter(cur);
        return cur;
    }

    let quizVisibilityObserver = null;
    UserSynonyms2.showCurrentSynonyms = function() {
        if (quizVisibilityObserver) {
            quizVisibilityObserver.disconnect();
            quizVisibilityObserver = null;
        }
        if ($.jStorage.get('l/quizActive')) {
            let currentQuizItem = $.jStorage.get('l/currentQuizItem');
            if (!currentQuizItem || !initialized_subject_ids.has(currentQuizItem.id)) {return}
            if (currentQuizItem.rad) {
                let el = $('#item-info-col1 section');
                if (!el) {return}
                let synSec = UserSynonyms2.synonymSection('append', el);
                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 el = $('#item-info-meaning');
                if (!el) {return}
                let synSec = UserSynonyms2.synonymSection('insert', el);
                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);
                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');
                    }
                });
            }
        } else {
            let currentLesson = $.jStorage.get('l/currentLesson');
            if (!currentLesson || !initialized_subject_ids.has(currentLesson.id)) {return}
            if (currentLesson.rad) {
                let synSec = UserSynonyms2.synonymSection('insert', $('#supplement-rad-name-mne'));
                UserSynonyms2.load(getSynListItem(currentLesson), currentLesson.id, synSec);
            } else if (currentLesson.kan) {
                let synSec = UserSynonyms2.synonymSection('insert', $('#supplement-kan-meaning .col1 div'));
                UserSynonyms2.load(getSynListItem(currentLesson), currentLesson.id, synSec);
            } else if (currentLesson.voc) {
                let synSec = UserSynonyms2.synonymSection('insert', $('#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
    let in_call = false;
    onEachJStorageChange('l/currentQuizItem', function (currentQuizItem) {
        if (!in_call) {
            in_call = true;
            if (!currentQuizItem.orignal_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 using in_call
            in_call = false;
        }
    });

    // 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();
    });

    // watch for completed items. completed items should be safe to delete local storage...
    (function () {
        let lastCompleted = 0;
        onEachJStorageChange('l/count/completed', function (completed) {
            let currentQuizItem, synKey;
            if (completed > lastCompleted) {
                currentQuizItem = $.jStorage.get('l/currentQuizItem');
                synKey = getSynKeyItem(currentQuizItem);
                $.jStorage.deleteKey(synKey); // harmless to delete non-existant keys
            }
            lastCompleted = completed;
        });
    }());


    // 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');
}());

Seems you’re right concerning my Firefox version ! But when requested, Firefox answered after a check that it’s the last available version (but Firefox 82 is available on Mozilla website for Linux ?).

Nethertheless, I installed your script on Opera on the same computer and “Add synonyms” now appears. But when I add a synonym, I find it again already in user synonyms in the next word… (I hope you understand !).

(And thank you, of course)

OK, so:

  • I deleted old Firefox 68 and installed new Firefox 82
  • I reinstalled ViolentMonkey 2.12.7 and three scripts (Open Framework, Dashboard Progress Level Detail and WK User Synonym v2).

First, the Dashbord progress bar doesn’t appear on the first page of WK website…
The “Add user synonym” button doesn’t appear during lesson.
So I edited the script with your last one, the button appears (yeah !) but the words I typed are immediately forgeted ! (boh !!!) .

The words I added two years ago are still there and appeared…

The new version below fixes the issue about synonyms staying the same for each word. For the issue about "the words I typed are immediately forgeted " could you try the new script and see if the problem is still there? If it is, could you provide very detailed instructions for how to reproduce the issue? (Every button you clicked and key you typed in order, and what happened compared to what should have happened?)

I don’t use dashboard progress plus, so I don’t know about the separate problems you are having with that. Maybe try asking in the thread on it?

// ==UserScript==
// @name          WaniKani Lesson User Synonyms 2 Fork
// @namespace     est_fills_cando
// @version       0.3.4
// @description   Allows adding synonyms during lesson and lesson quiz.
// @author        est_fills_cando
// @match         https://www.wanikani.com/lesson/session
// @grant         none
// ==/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 Set()
        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 initially
    // callOnNull is whether to call on null values (default:false)
    function onEachJStorageChange(key, callback, callOnNull) {
        let val = $.jStorage.get(key);
        if (val !== null || callOnNull)
            setTimeout(() => callback(val), 0); //setTimeout here because local storage events are macro tasks
        $.jStorage.listenKeyChange(key, function (key, action) {
            if (action === 'updated') {
                val = $.jStorage.get(key);
                if (val !== null || callOnNull)
                    callback(val);
            }
        });
    }


    // 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 = "https://www.wanikani.com/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) {
        if (initializedSkeletons.has(target.get(0) ))
            return initializedSkeletons.get(target.get(0));
        let cur;
        if (child_or_sibling =='sibling')
            cur =$('<h2>User Synonyms</h2>').insertAfter(target);
        else if (child_or_sibling == 'child')
            cur =$('<h2>User Synonyms</h2>').appendTo(target);
        cur = $('<section class="user-synonyms"></section>').insertAfter(cur);
        initializedSkeletons.set(target.get(0), cur);
        return cur;
    }

    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
    let in_call = false;
    onEachJStorageChange('l/currentQuizItem', function (currentQuizItem) {
        if (!in_call) {
            in_call = true;
            if (!currentQuizItem.orignal_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 using in_call
            in_call = false;
        }
    });

    // 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();
    });

    // watch for completed items. completed items should be safe to delete local storage...
    (function () {
        let lastCompleted = 0;
        onEachJStorageChange('l/count/completed', function (completed) {
            let currentQuizItem, synKey;
            if (completed > lastCompleted) {
                currentQuizItem = $.jStorage.get('l/currentQuizItem');
                synKey = getSynKeyItem(currentQuizItem);
                $.jStorage.deleteKey(synKey); // harmless to delete non-existant keys
            }
            lastCompleted = completed;
        });
    }());


    // 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

Hi again,
Thank you for your (hard ?) work: everything seems now to run perfectly !

Thanks for your work on this

1 Like

New Minor Update: Fixed an issue where answering with a synonym added earlier in the review session would result in a wrong answer.

// ==UserScript==
// @name          WaniKani Lesson User Synonyms 2 Fork
// @namespace     est_fills_cando
// @version       0.3.6
// @description   Allows adding synonyms during lesson and lesson quiz.
// @author        est_fills_cando
// @match         https://www.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 = "https://www.wanikani.com/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) {
        if (initializedSkeletons.has(target.get(0) ))
            return initializedSkeletons.get(target.get(0));
        let cur;
        if (child_or_sibling =='sibling')
            cur =$('<h2>User Synonyms</h2>').insertAfter(target);
        else if (child_or_sibling == 'child')
            cur =$('<h2>User Synonyms</h2>').appendTo(target);
        cur = $('<section class="user-synonyms"></section>').insertAfter(cur);
        initializedSkeletons.set(target.get(0), cur);
        return cur;
    }

    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];
        console.log(currentQuizItem);
        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

@irrelephant hasn’t visited the forums in half a year, so you might want to post it as a new script

Yes, please post it as a new script!

1 Like