Wanikani Open Framework [developer thread]

@rfindley I have noticed that there is a provision in the framework to have additional sources on top of Wanikani items in filters. How does this work? What do I need to do to add a source? I may have a use for this if the hassle is not too great.

@prouleau,

If I remember correctly, I think you just need to provide a ‘fetcher’ function that returns items.

Here’s an example.
See the startup() function at the end.

code
// ==UserScript==
// @name        Wanikani JLPT Items (for Self-Study Quiz)
// @namespace   rfindley
// @description Source for JLPT items in Self-Study Quiz
// @version     1.0.0
// @include     https://www.wanikani.com/*
// @copyright   2018+, Robin Findley
// @license     MIT; http://opensource.org/licenses/MIT
// @run-at      document-end
// @grant       none
// ==/UserScript==

window.jlpt_items = {};

(function(gobj) {

    /* global $, wkof */
    /* eslint no-multi-spaces: "off" */

    var jlpt_items_file = 'https://www.wkstats.com/cors/wanikani/jlpt_items_v0.0.1.js';

    //===================================================================
    // Initialization of the Wanikani Open Framework.
    //-------------------------------------------------------------------
    var script_name = 'JLPT Items (for Self-Study Quiz)';
    var wkof_version_needed = '1.0.45';
    if (!window.wkof) {
        if (confirm(script_name+' requires Wanikani Open Framework.\nDo you want to be forwarded to the installation instructions?')) {
            window.location.href = 'https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549';
        }
        return;
    }
    if (wkof.version.compare_to(wkof_version_needed) === 'older') {
        if (confirm(script_name+' requires Wanikani Open Framework version '+wkof_version_needed+'.\nDo you want to be forwarded to the update page?')) {
            window.location.href = 'https://greasyfork.org/en/scripts/38582-wanikani-open-framework';
        }
        return;
    }

    wkof.include('ItemData');
    wkof.ready('ItemData').then(startup);

    //===================================================================
    function jlpt_fetcher() {
        if (gobj.jlpt_items !== undefined) return Promise.resolve(gobj.jlpt_items);
        return wkof.load_script(jlpt_items_file, true /* use_cache */)
            .then(parse_items);
    }

    //===================================================================
    function parse_items() {
        var jlpt_items = {};
        var jlpt_level, items, item, idx;
        var id_cnt = 0;

        //------------------
        for (jlpt_level in gobj.kanji) {
            items = gobj.kanji[jlpt_level];
            for (idx in items) {
                item = items[idx];
                jlpt_items['jlpt_' + id_cnt++] = {
                    object: 'kanji',
                    data: {
                        jlpt_level: jlpt_level,
                        characters: item[0],
                        meanings: to_meanings(item[1]),
                        readings: to_yomi(item[2], item[3])
                    }
                };
            }
        }

        //------------------
        for (jlpt_level in gobj.vocabulary) {
            items = gobj.vocabulary[jlpt_level];
            for (idx in items) {
                item = items[idx];
                jlpt_items['jlpt_' + id_cnt++] = {
                    object: 'vocabulary',
                    data: {
                        jlpt_level: jlpt_level,
                        characters: item[0],
                        meanings: to_meanings(item[1]),
                        readings: to_readings(item[2], item[3])
                    }
                };
            }
        }

        //-------------------------------------------------------------------
        function to_meanings(text) {
            return split_list(text).map(function(meaning){return {meaning:meaning, primary:true, accepted_answer:true};});
        }
        //-------------------------------------------------------------------
        function to_yomi(kunyomi, onyomi) {
            return split_list(kunyomi).map(function(reading){return {type:'kunyomi', reading:reading, primary:true, accepted_answer:true};}).concat(
                split_list(onyomi).map(function(reading){return {type:'onyomi', reading:reading, primary:true, accepted_answer:true};})
            );
        }
        //-------------------------------------------------------------------
        function to_readings(text) {
            return split_list(text).map(function(reading){return {reading:reading, primary:true, accepted_answer:true};});
        }
        //-------------------------------------------------------------------

        return jlpt_items;
    }

    //-------------------------------------------------------------------
    function split_list(str) {return str.replace(/^\s+|\s*(,)\s*|\s+$/g, '$1').split(',').filter(function(name) {return (name.length > 0);});}

    //===================================================================
    var item_type_filter = {
        type: 'multi',
        label: 'Item type',
        default: [],
        content: {kanji:'Kanji',vocabulary:'Vocabulary'},
        filter_func: function(filter_value, item){return filter_value[item.object] === true;},
        hover_tip: 'Filter by item type (kanji, vocabulary)'
	};

    //===================================================================
    var jlpt_level_filter = {
        type: 'multi',
        label: 'JLPT Level',
        default: [],
        content: {N5:'N5',N4:'N4',N3:'N3',N2:'N2',N1:'N1'},
        filter_func: function(filter_value, item){return filter_value[item.data.jlpt_level] === true;},
        hover_tip: 'Filter by JLPT Level (N5, N4, N3, N2, N1)'
	};

    //===================================================================
    function startup() {
        wkof.ItemData.registry.sources.jlpt_items = {
            description:"JLPT",
            fetcher:jlpt_fetcher,
            filters:{
                item_type:item_type_filter,
                jlpt_level:jlpt_level_filter,
            },
            options:{}
        };
    }

})(window.jlpt_items);
1 Like

Thanks. The filter part seems easy enough. Integrating multiple sources of information in Item Inspector is not so easy. I will look at what I can do.

This is a bug report. I have experimented with setting up multiple sources. If we request filters for more than one source in a configuration object parameter for wkof.ItemData.get_items() bugs happen.

The problem is that ItemData() runs loops that sets up promises that uses data prepared by the loop. If there is more than one iteration of the loop the data is changed by the next iteration of the loop before it is consumed by the promises. This results into the promises consuming the wrong data. This doesn’t happen when there is only one source of data because there is no subsequent iterations of the loops to alter the data.

The faulty variables are identified in red. There are three of them.

I will look into this in about 2 weeks.

2 Likes

There is no rush. Take all the time you need.

A few more things for your information:

  • I am doing my complex filters with dialog type instead of html type filters. This approach does not need modifications to the wkof framework, unlike the html type. You may put the html type to the lowest priority because no one waits for this anymore.

  • Dialog type require a small modification to Self Study Quiz. A path needs to be given to the filter where the parameter will be stored in the settings. Currently SS Quiz doesn’t provide a path. Again there is no rush. I detect when the filters are called from SS Quiz and figure out the path because I know how SS Quiz constructs its paths. This workaround will work for as long as you need before a proper path is available…

  • I plan to add traditional radicals as an source of items. This source won’t work in SS Quiz because the shuffle function filters the items according to their types (rad, kan voc). Traditional radicals can theoretically be quizzable because they have meanings and readings but they don’t have a WK type. They get filtered away by the shuffle function. It is up to you whether you want to expand SS Quiz to quiz on traditional radicals once the new source is functional. Again there is no rush.

@rfindley I have a question about filters. Is there a reason filter_value_map() is called before prepare() in wkok.ItemData.get_items()? This code is in the apply_filters() function.

I want to use prepare() to delay the loading of some data and the making of indexes needed by a filter until the filter is actually used. The intent is to avoid unnecessary latency when the filter is not used. I need the data to be loaded and indexed before filter_value_map() is called because filter_value_map() uses this data. I need filter_value_map() to be called after the promise returned by prepare() is resolved. Is there any hope of having the framework fixed to do this?

@prouleau,
There is no reason for the order, so I can change it.

1 Like

Great! Thank you very much. When do you plan to do it? There is no rush. Do it when it is convenient for you.

@prouleau,
Now that I’m looking at the code, I see filter_value_map()'s results are passed to prepare(), so prepare() must be called second. I think the intent was that the prepare() function can selectively load data based on what the user selected.

I would suggest:
Instead of setting filter_value_map in the filter spec, just call your filter_value_map function at the end of your prepare() function. Would that work?

Edit: Hmm… that won’t work, because there’s no way to pass the mapped value back to the framework. I’ll have to think about this a little more.

1 Like

My two cents on the topic.

There are only two filters that uses prepare. They are the Failed Last Review filter and the Time Until Review filters from @seanblue Additional Filters suite. Here is the code.

Failed Last Review

	function failedLastReviewPrepare() {
		// Only set "now" once so that all items use the same value when filtering.
		nowForFailedLastReview = Date.now();
	}

Time Until Review

	function timeUntilReviewPrepare() {
		// Only set "now" once so that all items use the same value when filtering.
		nowForTimeUntilReview = Date.now();
	}

These filters do not use the parameter from filter_value_map().

I have written no filter using prepare() and I know that @Kumirei and yourself have not either. I think in these circumstances you have a lot of leeway to do what should be done without fear of breaking existing filters.

This is part of what I want to do and I don’t need preprocessing from filter_value_map() for this. Passing the raw filter value to prepare() is good enough because prepare() can do all the preprocessing filter_value_map() is able to do.

Besides preprocessing for filter_func() is different from preprocessing for prepare(). Why would it be a good idea to use filter_value_map() for both purposes? In my opinion these two functionalities should be separate.

Thanks for saving me the work of researching what others have implemented so far!
I’ll go ahead and swap the order of filter_value_map() and prepare(), and will pass the non-mapped filter value to prepare().

Depending how things go with upgrading my home server storage, I may get it done tonight. If not, it will probably be Sunday night.

1 Like

Happy New Year! :partying_face:

You are welcome.

Will filter_value_map() be called after the promise from prepare() be resolved? Say like in a .then() clause? If not filter_value_map() will be called too early to use the data that will be prepared. This will be a problem for me. Or is there a way for filter_value_map() to wait for prepare() to be completed?

I am thinking of a wkof.wait_state() and wkof.set_state() combo but this would cause filter_value_map() to return a promise because it would call wkof.wait_state(), won’t it?

A pair of thoughts occurred to me…

  • If you can’t call filter_value_map() after the promise from prepare() is resolved this will not be a big deal for me. I can assign a mapped filter value to a global variable in prepare() and use this global in filter_func() instead of the filtered value passed as a parameter. This is not my first choice solution but at least I have a way to handle whatever you choose to do.

  • There is one more scenario worth considering. I am not saying I want this scenario. I just raise it in case you find this is a good idea. Perhaps this trick will simplify your changes to the ItemData.get_items code. If you don’t like the idea I will just forget about it.

    • The promise that is set up in prepare() may be resolved into a mapped value without having to call filter_value_map()

    • If we want to return a promise in prepare() but don’t want a mapped value we just resolve the promise into the unmapped filter value.

    • No existing filters use promises returned from prepare(). This change of semantics for the promise will not break anything.

    • The downside is that prepare() can’t return a mapped value when no promises are returned without breaking seanblue’s filters that use prepare(). There will be an inconsistency in behavior depending on whether a promise is returned or not.

As far as I can tell this change still hasn’t been done. This is fine for me. I have found a way to do my filters with the current call order. In fact it is now better for me if this change is not done.

The trick is to set up an object as the mapped value in filter_value_map(). I will modify the object attributes within prepare() once the data is ready. There will be no need to notify the framework of the change because objects are mutable. The framework already has a pointer to the object that will be passed in its modified state to the filter_func() call.

I have not yet tested this idea but I see no reason it wouldn’t work.

1 Like

Ok, sounds good. Let me know how it turns out.

I didn’t end up working on it on Sunday due to shipping delays affecting my work schedule. I’m finishing up assembling a circuit board today, and testing it tomorrow. If all goes well, I’m a free man on Thursday. (yay!)

2 Likes

Has this ever happened before?

1 Like

Will do.

Great news! :partying_face: I suppose the crowd waiting for wkstats to be completed will rejoice.

Personally I am waiting for the bug in wkof.ItemData.get_items() when multiple iterations of a loop mutates variables that are used in apply_filters(). There is no rush. Take the time you need.

2 Likes

I might as well point out that there is no hurry to fix any of the issues I have reported; I don’t mind using workarounds

1 Like

Heh! I don’t blame you for wondering :slight_smile:

When I have all the info I need, and enough time to be thorough and do things right, things often go well. And I think this is one of those times.

Basically, I have a high degree of confidence that I can deliver (tomorrow) exactly what the client asked for. Then, in about a week, I’ll find out if what the customer asked for is really what they wanted :slight_smile:

4 Likes