[Userscript] WaniKani Lesson User Synonyms v2

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

1 Like

I think this will work

// ==UserScript==
// @name          WaniKani Lesson User Synonyms 2 Fork
// @namespace     est_fills_cando
// @version       0.3.8
// @description   Allows adding synonyms during lesson and lesson quiz.
// @author        est_fills_cando
// @match         https://www.wanikani.com/lesson/session
// @match         https://preview.wanikani.com/lesson/session
// @grant         none
// @run-at        document-end
// ==/UserScript==/
/*global $, wkof */

// based heavily on the WaniKani Lesson User Synonyms 2 userscript

(function () {
    // Make sure WKOF is installed
    if (!wkof) {
        let response = confirm(script_name+' requires WaniKani Open Framework.\n Click "OK" to be forwarded to installation instructions.');
        if (response) {
            window.location.href = 'https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549';
        }
        return;
    }

    wkof.include('Apiv2');

    let UserSynonyms2 = {};
    let keyTTL = 180 * 24 * 3600 * 1000; // 180 days should be enough for anyone

    // subject ids are unique even across different subject types
    // the subject type is not required when updating the synonym list via the api
    // therefore, we can ignore subject types
    function getSynKeyItem(item) {
        return getSynKeyID(item.id);
    }

    function getSynKeyID(id) {
        return 'l/syn/' + id;
    }

    function getSynListItem(item) {
        return getSynListID(item.id);
    }

    function getSynListID(id) {
        let synKey = getSynKeyID(id);
        $.jStorage.setTTL(synKey, keyTTL); // extend TTL on read. setting TTL on non-existant keys seems fine.
        let retVal = $.jStorage.get(synKey) || [];
        return retVal;
    }

    function setSynListID(id, synList) {
        let synKey = getSynKeyID(id);
        if (synList.length > 0) {
            $.jStorage.set(synKey, synList);
            $.jStorage.setTTL(synKey, keyTTL);
        } else {
            $.jStorage.deleteKey(synKey);
        }
    }

    // Calls callback once on each element matching selector that is added
    // to the DOM, including existing elements at the time the function is first called.
    // options is options for the MutationObserver (optional, is merged with default options  {childList: true, subtree: true})
    function onEachElementReady(selector, options, callback) {
        if (!options)
            options = {};

        // optimizations for certain commonly used selector patterns

        let els_found = new WeakSet()
        function check_available() {
            for (const el of document.querySelectorAll(selector)) {
                if (!els_found.has(el)) {
                    els_found.add(el);
                    callback(el);
                }
            }
        }

        queueMicrotask(check_available);

        let mo = new MutationObserver(check_available);
        mo.observe(document.documentElement, {
            childList: true,
            subtree: true,
            ...options
        });
    }

    // runs the callback every time attributes of elt change
    // including once initially
    // this function only works if elt exists at the time it is called
    function onEachAttrChange(elt, options, callback) {
        if (!options)
            options = {}
        options = {attributes: true, ...options};
        queueMicrotask(callback);
        let mo = new MutationObserver(callback);
        mo.observe(elt, options);
        return mo;
    }

    // calls callback each time jStorage value stored at key,
    // is updated with the updated value,
    // including cases where the value is overwritten with itself,
    // including once for the initial value when this function is executed
    // options is an object or unset
    // options.callOnNull is whether to call on null values (default:false)
    // options.priority is one of 'microtask', 'synchronous' (default: 'microtask')
    //                  and specifies how callback should be called.
    //                  Changes triggered by other windows or that bypass jstorage by manipulating
    //                  localStorage directly will always be called as a macrotask if priority is set
    //                  to synchronous or as a macrotask that then triggers a microtask if priroity is
    //                  set to microtask.
    // options.noRecursion (default:false) if true, an event handler that triggers itself while handling an
    //                                     event will not cause a subsequent call to itself (other event handlers
    //                                     may still be triggered)
    function onEachJStorageChange(key, callback, options) {
        if (!options)
            options = {};
        if (!options.priority)
            options.priority = 'microtask';
        if (!['microtask', 'synchronous'].includes(options.priority))
            throw 'invalid value for options.priority';
        const callOnNull = options.callOnNull;
        let in_call = false;
        callback = (function(callback) {return function() {
            in_call = true;
            let val = $.jStorage.get(key);
            try {
                if (val !== null || callOnNull)
                    callback(val);
            } finally {
                in_call = false;
            }
        }})(callback);
        if (options.priority == 'microtask')
            callback = (function(callback) {return function() {
                queueMicrotask(callback)
            }})(callback);
        if (options.noRecursion)
            callback = (function(callback) {return function() {
                if (!in_call)
                    callback();
            }})(callback);
        $.jStorage.listenKeyChange(key, function (key, action) {
            if (action === 'updated') {
                callback();
            }
        });
        callback(); // jStorage event handlers are called synchronously
    }


    // loads any user synonyms stored on WaniKani's servers into the locally stored lists of synonyms,
    // clobbering any existing values
    // also stores subject ids of each subject
    // input is array of subject_ids to retrieve
    UserSynonyms2.loadSynonymsFromAPI = async function (ids) {
        if (ids.length == 0)
            return new Promise(resolve => resolve([]));
        let options = {
            force_update: true,
            filters: {
                subject_ids: ids
            }
        };

        await wkof.ready('Apiv2');
        let mats = await wkof.Apiv2.fetch_endpoint('study_materials', options);
        let syns = {};
        for (const mat of mats.data) {
            syns[mat.data.subject_id] = mat.data.meaning_synonyms;
        }

        for (const id of ids) {
            let syns_for_id = [];
            if (id in syns) {
                syns_for_id = syns[id];
            }
            setSynListID(id, syns_for_id);
        }
    }

    // removes the given synonym, if it exists, from the given item in jStorage
    // and saves the synonym list
    UserSynonyms2.removeAndSave = function (itemID, syn) {
        let li = getSynListID(itemID).filter(entry => entry != syn);
        return UserSynonyms2.saveSynonymList(itemID, li);
    }

    // adds the given synonym to the given item in jStorage
    // and saves the synonym list
    UserSynonyms2.addAndSave = function (itemID, syn) {
        let li = getSynListID(itemID);
        li.push(syn);
        return UserSynonyms2.saveSynonymList(itemID, li);
    }


    // prevent enter key up, which happens after textbox disappears
    // and therefore triggers on body, from advancing lesson/quiz
    let enterKeypressStartedInInput = false;
    function setKeyInterceptor(selector) {
        $(selector).keydown(function (event) {
            if (event.key == "Enter" && enterKeypressStartedInInput) {
                event.stopPropagation();
            }
        });
        $(selector).keyup(function (event) {
            if (event.key == "Enter" && enterKeypressStartedInInput) {
                event.stopPropagation();
                enterKeypressStartedInInput = false;
            }
        });
    }

    UserSynonyms2.load = function (userSynStr, itemID, element) {
        if (!element || element.length == 0)
            return
        UserSynonyms2.generateList(element, userSynStr);
        UserSynonyms2.addOption(itemID);
        UserSynonyms2.removeOption(itemID);

        // recalculate positioning of navigation bar at bottom of lesson page
        // only seems to be needed on initial page load when information section
        // is long enough that it nearly reaches the bottom of the page
        // do it in request animation frame instead of setttimeout
        // to prevent the nav bar visually "jumping" from wrong spot to right spot
        // don't use microtasks because the values don't seem to have perfectly
        // stabilized at that point
        window.requestAnimationFrame(function() {
            var e= $("#supplement-info");
            var t=$("#batch-items");
            if (t.length > 0) {
                if($(window).height() - e.offset().top - e.outerHeight(!0) > t.height() + 25) {
                    t.get(0).classList.add("bottom");
                } else {
                    t.get(0).classList.remove("bottom");
                }
            }
        });
    };

    UserSynonyms2.addOption = function (itemID) {
        let btnAdd, wrapper;
        btnAdd = $('.user-synonyms-add-btn');
        wrapper = UserSynonyms2.wrapper();
        btnAdd.off('click');
        btnAdd.on('click', function () {
            let inputBtnRemove, inputBtnAdd, inputLi, inputForm, inputInput;
            $(this).hide();
            inputLi = $('<li></li>', {
                'class': 'user-synonyms-add-form'
            }).appendTo(wrapper);
            inputForm = $('<form></form>').appendTo(inputLi);
            inputInput = $('<input></input>', {
                type: 'text',
                autocapitalize: 'off',
                autocomplete: 'off',
                autocorrect: 'off'
            }).appendTo(inputForm).focus();
            inputInput.keyup(function (event) {
                event.stopPropagation();
                if (event.key == "Enter") {
                    enterKeypressStartedInInput = false;
                }
            }); // prevent hotkeys while typing
            inputInput.keydown(function (event) {
                event.stopPropagation();
                if (event.key == "Enter") {
                    event.preventDefault(); // not sure if necessary
                    enterKeypressStartedInInput = true;
                    inputBtnAdd.click();
                }
            }); // prevent hotkeys while typing
            inputBtnAdd = $('<button></button>', {
                type: 'submit',
                text: 'Add'
            }).appendTo(inputForm);
            inputBtnRemove = $('<button></button>', {
                type: 'button',
                html: '<i class="icon-remove"></i>'
            }).appendTo(inputForm);
            inputBtnAdd.off('click');
            inputBtnAdd.on('click', function (event) {
                event.preventDefault();
                let newSynText, synLiElems, newLen;
                newSynText = inputInput.val();
                // fake accept duplicate, but don't send to server (server doesn't check?)
                if (getSynListID(itemID).indexOf(newSynText.toLowerCase()) >= 0) {
                    // delay for key events to be caught
                    setTimeout(function () {
                        inputLi.remove();
                        btnAdd.show();
                    }, 100);
                    return;
                }
                synLiElems = wrapper.find('li');
                newLen = newSynText.length + synLiElems.slice(0, synLiElems.size() - 1).text().length;
                if (newLen > 255) {
                    inputBtnAdd.attr('disabled', 'disabled').text('Exceeded Synonym Limit');
                } else if (newSynText.trim().length !== 0) {
                    UserSynonyms2.addAndSave(itemID, newSynText).then(function() {
                        let newSynElem;
                        newSynElem = $('<li class="user-synonym">' + newSynText + '</li>');
                        wrapper.find('li:last').prev().before(newSynElem);
                        inputLi.remove();
                        btnAdd.show();
                        UserSynonyms2.removeOption(itemID);
                    }).catch(function() {
                        alert('adding synonym failed');
                    });
                }
            });
            inputBtnRemove.off('click');
            inputBtnRemove.on('click', function (event) {
                inputLi.remove();
                btnAdd.show();
            });
        });
    };

    /**
     * Save the list of user synonms currently in local storage
     * Only works if subject with itemID is in the activeQueue
     * @param itemID
     */
    UserSynonyms2.saveSynonymList = async function (itemID, synList) {
        function getType(item) {
            var type = 'x';
            if (item.rad) {
                type = 'radical';
            } else if (item.kan) {
                type = 'kanji';
            } else if (item.voc) {
                type = 'vocabulary';
            }
            return type;
        }

        let its = $.jStorage.get('l/activeQueue').filter(it => it.id == itemID);
        let it;
        if (its.length > 0)
            it = its[0];
        else
            it = $.jStorage.get('l/currentQuizItem')
        let type = getType(it);

        //let url = "https://api.wanikani.com/v2/study_materials/" + itemID;
        let url = "/study_materials/" + itemID;

        await $.ajax({
            type: 'PUT',
            url: url,
            contentType: 'application/json; charset=utf-8',
            data: JSON.stringify({
                study_material: {
                    subject_type: type,
                    subject_id: itemID,
                    meaning_synonyms: synList
                }
            })
        });

        setSynListID(itemID, synList);
    }

    UserSynonyms2.generateList = function (element, userSyn) {
        let wrapper, i;
        $('.user-synonyms ul').remove();
        element.append($('<ul></ul>'));
        wrapper = UserSynonyms2.wrapper();

        for (i = 0; i < userSyn.length; i++) {
            $('<li class="user-synonym">' + userSyn[i] + '</li>', {
                title: 'Click to remove synonym'
            }).appendTo(wrapper);
        }
        $('<li></li>', {
            html: '&nbsp;',
            title: 'Add your own synonym',
            'class': 'user-synonyms-add-btn'
        }).appendTo(wrapper);
    };

    UserSynonyms2.removeOption = function (itemID) {
        let synElems = UserSynonyms2.wrapper().find('li:not(.user-synonyms-add-btn):not(.user-synonyms-add-form)');
        synElems.off('click');
        synElems.on('click', function () {
            let clickedSynElem = $(this);
            UserSynonyms2.removeAndSave(itemID, clickedSynElem.text()).then(clickedSynElem.remove.bind(clickedSynElem));
        });
    };

    UserSynonyms2.wrapper = function () {
        return $('.user-synonyms ul');
    };

    // from: https://gist.githubusercontent.com/arantius/3123124/raw/grant-none-shim.js
    function addStyle(aCss) {
        let head, style;
        head = document.getElementsByTagName('head')[0];
        if (head) {
            style = document.createElement('style');
            style.setAttribute('type', 'text/css');
            style.textContent = aCss;
            head.appendChild(style);
            return style;
        }
        return null;
    }

    // Init / UI Display

    let initializedSkeletons = new Map();
    UserSynonyms2.synonymSection = function(child_or_sibling, target) {
        let header, body;
        if (initializedSkeletons.has(target.get(0))) {
            [header, body] = initializedSkeletons.get(target.get(0));
            return body;
        } else {
            header = $('<h2>User Synonyms</h2>');
            body = $('<section class="user-synonyms"></section>');
        }

        /*for (let [h, b] of initializedSkeletons.values()) {
            if (h!==header)
                h.remove();
            if (b!==body)
                b.remove();
        }*/

        if (child_or_sibling =='sibling')
            header.insertAfter(target);
        else if (child_or_sibling == 'child')
            header.appendTo(target);
        body.insertAfter(header);
        initializedSkeletons.set(target.get(0), [header, body]);
        return body;
    }

    let quizVisibilityObserver = null;
    UserSynonyms2.showCurrentSynonyms = function() {
        if (quizVisibilityObserver) {
            quizVisibilityObserver.disconnect();
            quizVisibilityObserver = null;
        }
        if ($.jStorage.get('l/quizActive')) {
            let el, synSec;
            let currentQuizItem = $.jStorage.get('l/currentQuizItem');
            if (!currentQuizItem || !initialized_subject_ids.has(currentQuizItem.id)) {return}
            if (currentQuizItem.rad) {
                el = $('#item-info-col1 section:first-child');
                if (el.length==0) {return}
                synSec = UserSynonyms2.synonymSection('child', el);
            } else {
                el = $('#item-info-meaning');
                if (el.length==0) {return}
                synSec = UserSynonyms2.synonymSection('sibling', el);
                quizVisibilityObserver = onEachAttrChange(el.get(0), null, function() {
                    if (window.getComputedStyle(el.get(0)).display != 'none') {
                        synSec.css('display','');
                        synSec.prev().css('display','');
                    } else {
                        synSec.css('display', 'none');
                        synSec.prev().css('display', 'none');
                    }
                });
            }
            el.contents().filter(function() {return this.nodeType==Node.TEXT_NODE}).get(0).textContent = currentQuizItem.original_answers_en.join(', ');
            UserSynonyms2.load(getSynListItem(currentQuizItem), currentQuizItem.id, synSec);
        } else {
            let currentLesson = $.jStorage.get('l/currentLesson');
            if (!currentLesson || !initialized_subject_ids.has(currentLesson.id)) {return}
            let synSec;
            if (currentLesson.rad) {
                synSec = UserSynonyms2.synonymSection('sibling', $('#supplement-rad-name-mne'));
                UserSynonyms2.load(getSynListItem(currentLesson), currentLesson.id, synSec);
            } else if (currentLesson.kan) {
                synSec = UserSynonyms2.synonymSection('sibling', $('#supplement-kan-meaning .col1 div'));
                UserSynonyms2.load(getSynListItem(currentLesson), currentLesson.id, synSec);
            } else if (currentLesson.voc) {
                synSec = UserSynonyms2.synonymSection('sibling', $('#supplement-voc-synonyms'));
                UserSynonyms2.load(getSynListItem(currentLesson), currentLesson.id, synSec);
            }
            synSec.show();
        }
    }

    // during lesson

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

    // at start of each lesson set (detected via activeQueue change)
    // load synonym data from api
    let initialized_subject_ids = new Set();
    onEachJStorageChange('l/activeQueue', function(activeQueue) {
        let subject_ids = activeQueue.map(it => it.id).filter(id => !initialized_subject_ids.has(id));
        subject_ids.forEach(id => initialized_subject_ids.add(id));
        if (subject_ids.length != 0) {
            UserSynonyms2.loadSynonymsFromAPI(subject_ids).then(function() {
                UserSynonyms2.showCurrentSynonyms();
            });
        }
    });

    // show user synonyms when looking at each lesson
    onEachJStorageChange('l/currentLesson', function (currentLesson) {
        UserSynonyms2.showCurrentSynonyms();
    });

    // during lesson quiz

    // copy user synonyms into the answers list for quiz
    onEachJStorageChange('l/currentQuizItem', function (currentQuizItem) {
        currentQuizItem = {...currentQuizItem}; // object we get is frozen for some reason so copy to new object to unfreeze
        if (!currentQuizItem.original_answers_en)
            currentQuizItem.original_answers_en = [...currentQuizItem.en];
        currentQuizItem.en = currentQuizItem.original_answers_en.concat(getSynListItem(currentQuizItem));
        $.jStorage.set('l/currentQuizItem', currentQuizItem); // this call would cause infinite recursion without checking for change
    }, {noRecursion: true});

    // show user synonyms in information box in lesson quiz for radicals
    onEachElementReady('#item-info-col1 section:first-child', null, function(el) {
        if ($(el).find('h2').text() == 'Name')
            UserSynonyms2.showCurrentSynonyms();
    });

    // show user synonyms in information box in lesson quiz for kanji and vocab
    onEachElementReady('#item-info-meaning', null, function(el) {
        UserSynonyms2.showCurrentSynonyms();
    });

    // remove jstorage entries for completed items to prevent disk space usage leak
    onEachJStorageChange('l/activeQueue', function (queue) {
        let activeIds = new Set(queue.map((it) => it.id));
        for (const id of new Set(initialized_subject_ids)) {
            if (!activeIds.has(id)) {
                let synKey = getSynKeyID(id);
                $.jStorage.deleteKey(synKey);
                initialized_subject_ids.delete(id);
            }
        }
    });

    // this is style extracted from the wanikani review page
    addStyle('\n' +
             '.user-synonyms ul {\n' +
             '    margin: 0;\n' +
             '    padding: 0;\n' +
             '}\n' +
             '.user-synonyms ul li {\n' +
             '    display: inline-block;\n' +
             '    line-height: 1.5em;\n' +
             '}\n' +
             '.user-synonyms ul li:not(.user-synonyms-add-btn):not(.user-synonyms-add-form) {\n' +
             '    cursor: pointer;\n' +
             '    vertical-align: middle;\n' +
             '}\n' +
             '.user-synonyms ul li:not(.user-synonyms-add-btn):not(.user-synonyms-add-form):after {\n' +
             '    background-color: #EEEEEE;\n' +
             '    border-radius: 3px;\n' +
             '    color: #A2A2A2;\n' +
             '    content: "\\f00d";\n' +
             '    font-family: FontAwesome;\n' +
             '    font-size: 0.5em;\n' +
             '    margin-left: 0.5em;\n' +
             '    margin-right: 1.5em;\n' +
             '    padding: 0.15em 0.3em;\n' +
             '    transition: background-color 0.3s linear 0s, color 0.3s linear 0s;\n' +
             '    vertical-align: middle;\n' +
             '}\n' +
             '.user-synonyms ul li:hover:not(.user-synonyms-add-btn):not(.user-synonyms-add-form):after {\n' +
             '    background-color: #FF0033;\n' +
             '    color: #FFFFFF;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-btn {\n' +
             '    cursor: pointer;\n' +
             '    display: block;\n' +
             '    font-size: 0.75em;\n' +
             '    margin-top: 0.25em;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-btn:after {\n' +
             '    content: "";\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-btn:before {\n' +
             '    background-color: #EEEEEE;\n' +
             '    border-radius: 3px;\n' +
             '    color: #A2A2A2;\n' +
             '    content: "+ ADD SYNONYM";\n' +
             '    margin-right: 0.5em;\n' +
             '    padding: 0.15em 0.3em;\n' +
             '    transition: background-color 0.3s linear 0s, color 0.3s linear 0s;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-btn:hover:before {\n' +
             '    background-color: #A2A2A2;\n' +
             '    color: #FFFFFF;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-form {\n' +
             '    display: block;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-form form {\n' +
             '    display: block;\n' +
             '    margin: 0;\n' +
             '    padding: 0;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-form form input, .user-synonyms ul li.user-synonyms-add-form form button {\n' +
             '    line-height: 1em;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-form form input {\n' +
             '    -moz-border-bottom-colors: none;\n' +
             '    -moz-border-left-colors: none;\n' +
             '    -moz-border-right-colors: none;\n' +
             '    -moz-border-top-colors: none;\n' +
             '    border-color: -moz-use-text-color -moz-use-text-color #A2A2A2;\n' +
             '    border-image: none;\n' +
             '    border-style: none none solid;\n' +
             '    border-width: 0 0 1px;\n' +
             '    display: block;\n' +
             '    margin: 0;\n' +
             '    outline: medium none;\n' +
             '    padding: 0;\n' +
             '    width: 100%;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-form form button {\n' +
             '    background-color: #EEEEEE;\n' +
             '    border: medium none;\n' +
             '    border-radius: 3px;\n' +
             '    color: #A2A2A2;\n' +
             '    font-size: 0.75em;\n' +
             '    outline: medium none;\n' +
             '    transition: background-color 0.3s linear 0s, color 0.3s linear 0s;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-form form button:hover {\n' +
             '    background-color: #A2A2A2;\n' +
             '    color: #FFFFFF;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-form form button:disabled {\n' +
             '    background-color: #FF0000;\n' +
             '    color: #FFFFFF;\n' +
             '    cursor: default;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-form form button[type="button"] {\n' +
             '    margin-left: 0.25em;\n' +
             '    padding-left: 0.3em;\n' +
             '    padding-right: 0.3em;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-form form button[type="button"]:hover {\n' +
             '    background-color: #FF0000;\n' +
             '    color: #FFFFFF;\n' +
             '}\n');
})();
1 Like

Compatibility with upcoming changes to lessons that use React components.

// ==UserScript==
// @name          WaniKani Lesson User Synonyms 2 Fork
// @namespace     est_fills_cando
// @version       0.3.11
// @description   Allows adding synonyms during lesson and lesson quiz.
// @author        est_fills_cando
// @match         https://www.wanikani.com/lesson/session
// @match         https://preview.wanikani.com/lesson/session
// @grant         none
// @run-at        document-end
// ==/UserScript==/
/*global $, wkof */

// based heavily on the WaniKani Lesson User Synonyms 2 userscript

(function () {
    // Make sure WKOF is installed
    if (!wkof) {
        let response = confirm(script_name+' requires WaniKani Open Framework.\n Click "OK" to be forwarded to installation instructions.');
        if (response) {
            window.location.href = 'https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549';
        }
        return;
    }

    wkof.include('Apiv2');

    let UserSynonyms2 = {};
    let keyTTL = 180 * 24 * 3600 * 1000; // 180 days should be enough for anyone

    // subject ids are unique even across different subject types
    // the subject type is not required when updating the synonym list via the api
    // therefore, we can ignore subject types
    function getSynKeyItem(item) {
        return getSynKeyID(item.id);
    }

    function getSynKeyID(id) {
        return 'l/syn/' + id;
    }

    function getSynListItem(item) {
        return getSynListID(item.id);
    }

    function getSynListID(id) {
        let synKey = getSynKeyID(id);
        $.jStorage.setTTL(synKey, keyTTL); // extend TTL on read. setting TTL on non-existant keys seems fine.
        let retVal = $.jStorage.get(synKey) || [];
        return retVal;
    }

    function setSynListID(id, synList) {
        let synKey = getSynKeyID(id);
        if (synList.length > 0) {
            $.jStorage.set(synKey, synList);
            $.jStorage.setTTL(synKey, keyTTL);
        } else {
            $.jStorage.deleteKey(synKey);
        }
    }

    // Calls callback once on each element matching selector that is added
    // to the DOM, including existing elements at the time the function is first called.
    // options is options for the MutationObserver (optional, is merged with default options  {childList: true, subtree: true})
    function onEachElementReady(selector, options, callback) {
        if (!options)
            options = {};

        // optimizations for certain commonly used selector patterns

        let els_found = new WeakSet()
        function check_available() {
            for (const el of document.querySelectorAll(selector)) {
                if (!els_found.has(el)) {
                    els_found.add(el);
                    callback(el);
                }
            }
        }

        queueMicrotask(check_available);

        let mo = new MutationObserver(check_available);
        mo.observe(document.documentElement, {
            childList: true,
            subtree: true,
            ...options
        });
    }

    // runs the callback every time attributes of elt change
    // including once initially
    // this function only works if elt exists at the time it is called
    function onEachAttrChange(elt, options, callback) {
        if (!options)
            options = {}
        options = {attributes: true, ...options};
        queueMicrotask(callback);
        let mo = new MutationObserver(callback);
        mo.observe(elt, options);
        return mo;
    }

    // calls callback each time jStorage value stored at key,
    // is updated with the updated value,
    // including cases where the value is overwritten with itself,
    // including once for the initial value when this function is executed
    // options is an object or unset
    // options.callOnNull is whether to call on null values (default:false)
    // options.priority is one of 'microtask', 'synchronous' (default: 'microtask')
    //                  and specifies how callback should be called.
    //                  Changes triggered by other windows or that bypass jstorage by manipulating
    //                  localStorage directly will always be called as a macrotask if priority is set
    //                  to synchronous or as a macrotask that then triggers a microtask if priroity is
    //                  set to microtask.
    // options.noRecursion (default:false) if true, an event handler that triggers itself while handling an
    //                                     event will not cause a subsequent call to itself (other event handlers
    //                                     may still be triggered)
    function onEachJStorageChange(key, callback, options) {
        if (!options)
            options = {};
        if (!options.priority)
            options.priority = 'microtask';
        if (!['microtask', 'synchronous'].includes(options.priority))
            throw 'invalid value for options.priority';
        const callOnNull = options.callOnNull;
        let in_call = false;
        callback = (function(callback) {return function() {
            in_call = true;
            let val = $.jStorage.get(key);
            try {
                if (val !== null || callOnNull)
                    callback(val);
            } finally {
                in_call = false;
            }
        }})(callback);
        if (options.priority == 'microtask')
            callback = (function(callback) {return function() {
                queueMicrotask(callback)
            }})(callback);
        if (options.noRecursion)
            callback = (function(callback) {return function() {
                if (!in_call)
                    callback();
            }})(callback);
        $.jStorage.listenKeyChange(key, function (key, action) {
            if (action === 'updated') {
                callback();
            }
        });
        callback(); // jStorage event handlers are called synchronously
    }


    // loads any user synonyms stored on WaniKani's servers into the locally stored lists of synonyms,
    // clobbering any existing values
    // also stores subject ids of each subject
    // input is array of subject_ids to retrieve
    UserSynonyms2.loadSynonymsFromAPI = async function (ids) {
        if (ids.length == 0)
            return new Promise(resolve => resolve([]));
        let options = {
            force_update: true,
            filters: {
                subject_ids: ids
            }
        };

        await wkof.ready('Apiv2');
        let mats = await wkof.Apiv2.fetch_endpoint('study_materials', options);
        let syns = {};
        for (const mat of mats.data) {
            syns[mat.data.subject_id] = mat.data.meaning_synonyms;
        }

        for (const id of ids) {
            let syns_for_id = [];
            if (id in syns) {
                syns_for_id = syns[id];
            }
            setSynListID(id, syns_for_id);
        }
    }

    // removes the given synonym, if it exists, from the given item in jStorage
    // and saves the synonym list
    UserSynonyms2.removeAndSave = function (itemID, syn) {
        let li = getSynListID(itemID).filter(entry => entry != syn);
        return UserSynonyms2.saveSynonymList(itemID, li);
    }

    // adds the given synonym to the given item in jStorage
    // and saves the synonym list
    UserSynonyms2.addAndSave = function (itemID, syn) {
        let li = getSynListID(itemID);
        li.push(syn);
        return UserSynonyms2.saveSynonymList(itemID, li);
    }


    // prevent enter key up, which happens after textbox disappears
    // and therefore triggers on body, from advancing lesson/quiz
    let enterKeypressStartedInInput = false;
    function setKeyInterceptor(selector) {
        $(selector).keydown(function (event) {
            if (event.key == "Enter" && enterKeypressStartedInInput) {
                event.stopPropagation();
            }
        });
        $(selector).keyup(function (event) {
            if (event.key == "Enter" && enterKeypressStartedInInput) {
                event.stopPropagation();
                enterKeypressStartedInInput = false;
            }
        });
    }

    UserSynonyms2.load = function (userSynStr, itemID, element) {
        if (!element || element.length == 0)
            return
        UserSynonyms2.generateList(element, userSynStr);
        UserSynonyms2.addOption(itemID);
        UserSynonyms2.removeOption(itemID);

        // recalculate positioning of navigation bar at bottom of lesson page
        // only seems to be needed on initial page load when information section
        // is long enough that it nearly reaches the bottom of the page
        // do it in request animation frame instead of setttimeout
        // to prevent the nav bar visually "jumping" from wrong spot to right spot
        // don't use microtasks because the values don't seem to have perfectly
        // stabilized at that point
        // Doesn't seem to be needed after react slides update
        if (window.WaniKani.features.hasOwnProperty('reactSlides') && !window.WaniKani.features.reactSlides) {
            window.requestAnimationFrame(function() {
                var e= $("#supplement-info");
                var t=$("#batch-items");
                if (t.length > 0) {
                    if($(window).height() - e.offset().top - e.outerHeight(!0) > t.height() + 25) {
                        t.get(0).classList.add("bottom");
                    } else {
                        t.get(0).classList.remove("bottom");
                    }
                }
            });
        }
    };

    UserSynonyms2.addOption = function (itemID) {
        let btnAdd, wrapper;
        btnAdd = $('.user-synonyms-add-btn');
        wrapper = UserSynonyms2.wrapper();
        btnAdd.off('click');
        btnAdd.on('click', function () {
            let inputBtnRemove, inputBtnAdd, inputLi, inputForm, inputInput;
            $(this).hide();
            inputLi = $('<li></li>', {
                'class': 'user-synonyms-add-form'
            }).appendTo(wrapper);
            inputForm = $('<form></form>').appendTo(inputLi);
            inputInput = $('<input></input>', {
                type: 'text',
                autocapitalize: 'off',
                autocomplete: 'off',
                autocorrect: 'off'
            }).appendTo(inputForm).focus();
            inputInput.keyup(function (event) {
                event.stopPropagation();
                if (event.key == "Enter") {
                    enterKeypressStartedInInput = false;
                }
            }); // prevent hotkeys while typing
            inputInput.keydown(function (event) {
                event.stopPropagation();
                if (event.key == "Enter") {
                    event.preventDefault(); // not sure if necessary
                    enterKeypressStartedInInput = true;
                    inputBtnAdd.click();
                }
            }); // prevent hotkeys while typing
            inputBtnAdd = $('<button></button>', {
                type: 'submit',
                text: 'Add'
            }).appendTo(inputForm);
            inputBtnRemove = $('<button></button>', {
                type: 'button',
                html: '<i class="icon-remove"></i>'
            }).appendTo(inputForm);
            inputBtnAdd.off('click');
            inputBtnAdd.on('click', function (event) {
                event.preventDefault();
                let newSynText, synLiElems, newLen;
                newSynText = inputInput.val();
                // fake accept duplicate, but don't send to server (server doesn't check?)
                if (getSynListID(itemID).indexOf(newSynText.toLowerCase()) >= 0) {
                    // delay for key events to be caught
                    setTimeout(function () {
                        inputLi.remove();
                        btnAdd.show();
                    }, 100);
                    return;
                }
                synLiElems = wrapper.find('li');
                newLen = newSynText.length + synLiElems.slice(0, synLiElems.size() - 1).text().length;
                if (newLen > 255) {
                    inputBtnAdd.attr('disabled', 'disabled').text('Exceeded Synonym Limit');
                } else if (newSynText.trim().length !== 0) {
                    UserSynonyms2.addAndSave(itemID, newSynText).then(function() {
                        let newSynElem;
                        newSynElem = $('<li class="user-synonym">' + newSynText + '</li>');
                        wrapper.find('li:last').prev().before(newSynElem);
                        inputLi.remove();
                        btnAdd.show();
                        UserSynonyms2.removeOption(itemID);
                    }).catch(function() {
                        alert('adding synonym failed');
                    });
                }
            });
            inputBtnRemove.off('click');
            inputBtnRemove.on('click', function (event) {
                inputLi.remove();
                btnAdd.show();
            });
        });
    };

    /**
     * Save the list of user synonms currently in local storage
     * Only works if subject with itemID is in the activeQueue
     * @param itemID
     */
    UserSynonyms2.saveSynonymList = async function (itemID, synList) {
        function getType(item) {
            var type = 'x';
            if (item.rad) {
                type = 'radical';
            } else if (item.kan) {
                type = 'kanji';
            } else if (item.voc) {
                type = 'vocabulary';
            }
            return type;
        }

        let its = $.jStorage.get('l/activeQueue').filter(it => it.id == itemID);
        let it;
        if (its.length > 0)
            it = its[0];
        else
            it = $.jStorage.get('l/currentQuizItem')
        let type = getType(it);

        //let url = "https://api.wanikani.com/v2/study_materials/" + itemID;
        let url = "/study_materials/" + itemID;

        await $.ajax({
            type: 'PUT',
            url: url,
            contentType: 'application/json; charset=utf-8',
            data: JSON.stringify({
                study_material: {
                    subject_type: type,
                    subject_id: itemID,
                    meaning_synonyms: synList
                }
            })
        });

        setSynListID(itemID, synList);
    }

    UserSynonyms2.generateList = function (element, userSyn) {
        let wrapper, i;
        $('.user-synonyms ul').remove();
        element.append($('<ul></ul>'));
        wrapper = UserSynonyms2.wrapper();

        for (i = 0; i < userSyn.length; i++) {
            $('<li class="user-synonym">' + userSyn[i] + '</li>', {
                title: 'Click to remove synonym'
            }).appendTo(wrapper);
        }
        $('<li></li>', {
            html: '&nbsp;',
            title: 'Add your own synonym',
            'class': 'user-synonyms-add-btn'
        }).appendTo(wrapper);
    };

    UserSynonyms2.removeOption = function (itemID) {
        let synElems = UserSynonyms2.wrapper().find('li:not(.user-synonyms-add-btn):not(.user-synonyms-add-form)');
        synElems.off('click');
        synElems.on('click', function () {
            let clickedSynElem = $(this);
            UserSynonyms2.removeAndSave(itemID, clickedSynElem.text()).then(clickedSynElem.remove.bind(clickedSynElem));
        });
    };

    UserSynonyms2.wrapper = function () {
        return $('.user-synonyms ul');
    };

    // from: https://gist.githubusercontent.com/arantius/3123124/raw/grant-none-shim.js
    function addStyle(aCss) {
        let head, style;
        head = document.getElementsByTagName('head')[0];
        if (head) {
            style = document.createElement('style');
            style.setAttribute('type', 'text/css');
            style.textContent = aCss;
            head.appendChild(style);
            return style;
        }
        return null;
    }

    // Init / UI Display

    let initializedSkeletons = new Map();
    UserSynonyms2.synonymSection = function(child_or_sibling, target) {
        let header, body;
        if (initializedSkeletons.has(target.get(0))) {
            [header, body] = initializedSkeletons.get(target.get(0));
            //return body;
        } else {
            header = $('<h2>User Synonyms</h2>');
            body = $('<section class="user-synonyms"></section>');
        }

        /*for (let [h, b] of initializedSkeletons.values()) {
            if (h!==header)
                h.remove();
            if (b!==body)
                b.remove();
        }*/

        if (child_or_sibling =='sibling')
            header.insertAfter(target);
        else if (child_or_sibling == 'child')
            header.appendTo(target);
        body.insertAfter(header);
        initializedSkeletons.set(target.get(0), [header, body]);
        return body;
    }

    let quizVisibilityObserver = null;
    UserSynonyms2.showCurrentSynonyms = function() {
        if (quizVisibilityObserver) {
            quizVisibilityObserver.disconnect();
            quizVisibilityObserver = null;
        }
        if ($.jStorage.get('l/quizActive')) {
            let el, synSec;
            let currentQuizItem = $.jStorage.get('l/currentQuizItem');
            if (!currentQuizItem || !initialized_subject_ids.has(currentQuizItem.id)) {return}
            if (currentQuizItem.rad) {
                el = $('#item-info-col1 section:first-child');
                if (el.length==0) {return}
                synSec = UserSynonyms2.synonymSection('child', el);
            } else {
                el = $('#item-info-meaning');
                if (el.length==0) {return}
                synSec = UserSynonyms2.synonymSection('sibling', el);
                quizVisibilityObserver = onEachAttrChange(el.get(0), null, function() {
                    if (window.getComputedStyle(el.get(0)).display != 'none' || $.jStorage.get('l/questionType') == 'meaning') {
                        synSec.css('display','');
                        synSec.prev().css('display','');
                    } else {
                        synSec.css('display', 'none');
                        synSec.prev().css('display', 'none');
                    }
                });
            }
            el.contents().filter(function() {return this.nodeType==Node.TEXT_NODE}).get(0).textContent = currentQuizItem.original_answers_en.join(', ');
            UserSynonyms2.load(getSynListItem(currentQuizItem), currentQuizItem.id, synSec);
        } else {
            let currentLesson = $.jStorage.get('l/currentLesson');
            if (!currentLesson || !initialized_subject_ids.has(currentLesson.id)) {return}
            let synSec;
            if (currentLesson.rad) {
                synSec = UserSynonyms2.synonymSection('sibling', $('#supplement-rad-name-mne'));
                UserSynonyms2.load(getSynListItem(currentLesson), currentLesson.id, synSec);
            } else if (currentLesson.kan) {
                synSec = UserSynonyms2.synonymSection('sibling', $('#supplement-kan-meaning .col1 div'));
                UserSynonyms2.load(getSynListItem(currentLesson), currentLesson.id, synSec);
            } else if (currentLesson.voc) {
                synSec = UserSynonyms2.synonymSection('sibling', $('#supplement-voc-synonyms'));
                UserSynonyms2.load(getSynListItem(currentLesson), currentLesson.id, synSec);
            }
            synSec.show();
        }
    }

    // during lesson

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

    // at start of each lesson set (detected via activeQueue change)
    // load synonym data from api
    let initialized_subject_ids = new Set();
    onEachJStorageChange('l/activeQueue', function(activeQueue) {
        let subject_ids = activeQueue.map(it => it.id).filter(id => !initialized_subject_ids.has(id));
        subject_ids.forEach(id => initialized_subject_ids.add(id));
        if (subject_ids.length != 0) {
            UserSynonyms2.loadSynonymsFromAPI(subject_ids).then(function() {
                UserSynonyms2.showCurrentSynonyms();
            });
        }
    });

    // show user synonyms when looking at each lesson
    onEachJStorageChange('l/currentLesson', function (currentLesson) {
        UserSynonyms2.showCurrentSynonyms();
    });

    // during lesson quiz

    // copy user synonyms into the answers list for quiz
    onEachJStorageChange('l/currentQuizItem', function (currentQuizItem) {
        currentQuizItem = {...currentQuizItem}; // object we get is frozen for some reason so copy to new object to unfreeze
        if (!currentQuizItem.original_answers_en)
            currentQuizItem.original_answers_en = [...currentQuizItem.en];
        currentQuizItem.en = currentQuizItem.original_answers_en.concat(getSynListItem(currentQuizItem));
        $.jStorage.set('l/currentQuizItem', currentQuizItem); // this call would cause infinite recursion without checking for change
    }, {noRecursion: true});

    // show user synonyms in information box in lesson quiz for radicals
    onEachElementReady('#item-info-col1 section:first-child', null, function(el) {
        if ($(el).find('h2').text() == 'Name')
            UserSynonyms2.showCurrentSynonyms();
    });

    // show user synonyms in information box in lesson quiz for kanji and vocab
    // and lesson for all
    onEachElementReady('#item-info-meaning, #supplement-rad-name-mne, #supplement-kan-meaning .col1, #supplement-voc-synonyms', null, function(el) {
        UserSynonyms2.showCurrentSynonyms();
    });

    // remove jstorage entries for completed items
    onEachJStorageChange('l/activeQueue', function (queue) {
        let activeIds = new Set(queue.map((it) => it.id));
        for (const id of new Set(initialized_subject_ids)) {
            if (!activeIds.has(id)) {
                let synKey = getSynKeyID(id);
                $.jStorage.deleteKey(synKey);
                initialized_subject_ids.delete(id);
            }
        }
    });

    // this is style extracted from the wanikani review page
    addStyle('\n' +
             '.user-synonyms ul {\n' +
             '    margin: 0;\n' +
             '    padding: 0;\n' +
             '}\n' +
             '.user-synonyms ul li {\n' +
             '    display: inline-block;\n' +
             '    line-height: 1.5em;\n' +
             '}\n' +
             '.user-synonyms ul li:not(.user-synonyms-add-btn):not(.user-synonyms-add-form) {\n' +
             '    cursor: pointer;\n' +
             '    vertical-align: middle;\n' +
             '}\n' +
             '.user-synonyms ul li:not(.user-synonyms-add-btn):not(.user-synonyms-add-form):after {\n' +
             '    background-color: #EEEEEE;\n' +
             '    border-radius: 3px;\n' +
             '    color: #A2A2A2;\n' +
             '    content: "\\f00d";\n' +
             '    font-family: FontAwesome;\n' +
             '    font-size: 0.5em;\n' +
             '    margin-left: 0.5em;\n' +
             '    margin-right: 1.5em;\n' +
             '    padding: 0.15em 0.3em;\n' +
             '    transition: background-color 0.3s linear 0s, color 0.3s linear 0s;\n' +
             '    vertical-align: middle;\n' +
             '}\n' +
             '.user-synonyms ul li:hover:not(.user-synonyms-add-btn):not(.user-synonyms-add-form):after {\n' +
             '    background-color: #FF0033;\n' +
             '    color: #FFFFFF;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-btn {\n' +
             '    cursor: pointer;\n' +
             '    display: block;\n' +
             '    font-size: 0.75em;\n' +
             '    margin-top: 0.25em;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-btn:after {\n' +
             '    content: "";\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-btn:before {\n' +
             '    background-color: #EEEEEE;\n' +
             '    border-radius: 3px;\n' +
             '    color: #A2A2A2;\n' +
             '    content: "+ ADD SYNONYM";\n' +
             '    margin-right: 0.5em;\n' +
             '    padding: 0.15em 0.3em;\n' +
             '    transition: background-color 0.3s linear 0s, color 0.3s linear 0s;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-btn:hover:before {\n' +
             '    background-color: #A2A2A2;\n' +
             '    color: #FFFFFF;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-form {\n' +
             '    display: block;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-form form {\n' +
             '    display: block;\n' +
             '    margin: 0;\n' +
             '    padding: 0;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-form form input, .user-synonyms ul li.user-synonyms-add-form form button {\n' +
             '    line-height: 1em;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-form form input {\n' +
             '    -moz-border-bottom-colors: none;\n' +
             '    -moz-border-left-colors: none;\n' +
             '    -moz-border-right-colors: none;\n' +
             '    -moz-border-top-colors: none;\n' +
             '    border-color: -moz-use-text-color -moz-use-text-color #A2A2A2;\n' +
             '    border-image: none;\n' +
             '    border-style: none none solid;\n' +
             '    border-width: 0 0 1px;\n' +
             '    display: block;\n' +
             '    margin: 0;\n' +
             '    outline: medium none;\n' +
             '    padding: 0;\n' +
             '    width: 100%;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-form form button {\n' +
             '    background-color: #EEEEEE;\n' +
             '    border: medium none;\n' +
             '    border-radius: 3px;\n' +
             '    color: #A2A2A2;\n' +
             '    font-size: 0.75em;\n' +
             '    outline: medium none;\n' +
             '    transition: background-color 0.3s linear 0s, color 0.3s linear 0s;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-form form button:hover {\n' +
             '    background-color: #A2A2A2;\n' +
             '    color: #FFFFFF;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-form form button:disabled {\n' +
             '    background-color: #FF0000;\n' +
             '    color: #FFFFFF;\n' +
             '    cursor: default;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-form form button[type="button"] {\n' +
             '    margin-left: 0.25em;\n' +
             '    padding-left: 0.3em;\n' +
             '    padding-right: 0.3em;\n' +
             '}\n' +
             '.user-synonyms ul li.user-synonyms-add-form form button[type="button"]:hover {\n' +
             '    background-color: #FF0000;\n' +
             '    color: #FFFFFF;\n' +
             '}\n');
})();
5 Likes

Hi!

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

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

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

A big thank you!

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

Thank you Sinyaven! reallt appreciate it : :smiley:

Hi again!

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

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

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

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

I would highly appreciate any guidance that could be provided.

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

1 Like

Sinyaven, it is now working.

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

1 Like

Thanks man. Got another broken script working.

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

How is it working for you these days?
The script suddenly stopped showing any synonyms for vocabulary altogether, though it still seems to show them for radicals and kanji.

broken altogether for me.

1 Like

Did you try to turn on Script Compatibility Mode in settings? It seemed to fix the issue for me, though I’m unsure if it affected other scripts in any unwanted way.

Hey! apologies for taking a while, I had no lessons lol

I realised I hadn’t been up to date, though compatibility mode fixed it. I updated to 3.11 but that still doesn’t work without compatibility mode. I don’t have radicals rn but it works for Kanji and Vocab, I’ll update once I can test it

It seems like this script is not maintained anymore and currently does not work correctly without the script compatibility mode, so I recreated its main functionality in a new script: WaniKani Lesson User Synonyms 3.

I did not use any code from the previous versions and left out the local storage of the synonyms – nowadays WaniKani does not restrict the creation of user synonyms only to learned items, so this workaround is no longer needed (as far as I can tell). Bonus feature: user synonyms that are on WaniKani’s hidden block list (meaning that WaniKani will not accept them as answers during reviews) will be displayed in red.

3 Likes

If it is a new script shouldn’t it have its own thread?

2 Likes

Oh wow, yes yes yes. Amazing work! It works flawlessly, you can even add synonyms during the afterlesson review. People need to see this.

I believe it’s possible for mods to make post a wiki (or just add a link to the new thread on top), so the thread can be saved. Judging by the number of interwiki links this thread is referenced in a lot of places so it would be a shame to break them.

1 Like

I think that makes sense in this case. I have created a new thread for my script.

1 Like

reminder for myself: make a script that automatically replaces
something->sth
somebody->sb
and omits to