Wanikani Open Framework [developer thread]

So I am hooking into the turbo event for the lessons to re-insert the script menu on every turbo navigation which is working. However I can’t seem to get the script menu to insert at all on the actual lesson quiz. Am I doing something wrong here. I am calling

wkof.Menu.insert_script_link()

Hmm… I did the latest round of Kana Vocab lessons today, and the script menu was there, so that at least indicates that the framework itself is working.

First, make sure you’re running the latest version of wkof. I figure this isn’t the issue, but it’s always best to check everything.

Next, verify (such as with console.log()) that your script is reaching the point where it calls wkof.Menu.insert_script_link(). It’s probably a good idea to log the info you’re passing into insert_script_link(). That will help you confirm that it’s doing what you think it’s doing, and it might give me something to investigate inside of wkof.

I believe that everything looks right with what is being passed to wkof.Menu.insert_script_link(). I went ahead and screenshoted the console for you to see. I used console.log() for both the object being passed to the insert script link and I also added the wkof object to be logged. They are both being logged at the point where I am trying to add the script menu. This same code is inserting the menu on the reviews and self study page.

Do you have code posted somewhere that I could test it? Also, what’s that yellow star next to the Home button in your screenshot? I wonder if somehow the menu is getting wiped out or hidden by another script (maybe not likely, but it’s possible).

Sure the code is on my github. The yellow star is from a script I maintain. It just turns on or off the “anki mode”. Thank you so much for taking a look at this. Im sure your busy. :sweat_smile:

Also on greasy fork

Thanks for the code link!

I’ve published an update for wkof to fix the insert_script_link() specifically on the Lesson Quiz page. I had overlooked the fact that the quiz has a different base URL than the lessons.

Second, I see you’re using wkof.on_page_event(). I don’t recommend using it since it was a quick-fix experiment solely to get Double-Check working quickly during the big WK change. It does work in very limited cases, but I will probably rewrite it or remove it someday.

Instead, the model I’ve started using is:

    /* globals Stimulus, wkof */

    const match_patterns = [
        '/subjects/review',
        '/subjects/lesson/quiz*',
        '/subjects/extra_study*',
    ];
    function url_matches(patterns,url) {patterns=patterns||match_patterns;url=url||window.location.pathname;if(url[0]!=='/')url=new URL(url).pathname;return ((Array.isArray(patterns)?patterns:[patterns]).findIndex((pattern)=>{let regex=new RegExp(pattern.replace(/[.+?^${}()|[\]\\]/g,'\\$&').replaceAll('*','.*'));return (regex.test(url));})>=0);}
    function is_turbo_page() {return (document.querySelector('script[type="importmap"]')?.innerHTML.match('@hotwired/turbo') != null);}
    function get_controller(name) {return Stimulus.getControllerForElementAndIdentifier(document.querySelector(`[data-controller~="${name}"]`),name);}

    if (is_turbo_page()) {
        try {app_load();} catch(e){}
        try {document.documentElement.addEventListener('turbo:load', page_load);} catch(e){}
        try {document.documentElement.addEventListener('turbo:before-render', before_page_render);} catch(e){}
        try {document.documentElement.addEventListener('turbo:frame-load', frame_load);} catch(e){}
        try {document.documentElement.addEventListener('turbo:before-frame-render', before_frame_render);} catch(e){}
    } else {
        try {app_load();} catch(e){}
        try {page_load({detail:{url:window.location.href},target:document.documentElement});} catch(e){}
        try {frame_load({target:document.documentElement});} catch(e){}
    }

    function app_load() {
        if (!url_matches(match_patterns)) return;
        // Do stuff here that only needs to run when Stimulus first starts.
    }
    function page_load() {
        if (!url_matches(match_patterns)) return;
        // Do stuff here that needs to be done each time a particular page loads.

        // NOTE FOR LESSON QUIZ PAGE: The Lesson Quiz page needs a delay, otherwise WK overwrites it immediately.
        setTimeout(() => {
            wkof.Menu.insert_script_link({ name: 'ankimode', submenu: 'Settings', title: 'Anki Mode', on_click: open_settings });
        }, 1);
    }
    function frame_load() {
        if (!url_matches(match_patterns)) return;
        // Do stuff here that needs to be done each time a particular frame loads.
    }
1 Like

Thanks a lot for fixing it!

Also thanks for the code it looks very thorough. I will implement that.

1 Like

I’ve updated the Open Framework to support recent changes to the WK Lessons page. Primarily, the Menu module now supports the new Lesson URLs.

7 Likes

I’ve updated Open Framework to use GreasyFork’s new “update.greasyfork.org” URL when loading modules. I had noticed that it was failing to load modules, if not already cached in the browser. I’m not sure how long it’s been behaving that way, but I haven’t heard any bug reports, so hopefully I just got lucky and happened to notice it early.

4 Likes

Thank you for keeping this updated throughout the years. Nearly all of my userscripts (including yours) depend on this framework to function. As I’m very close to reaching level 60 in the coming week, I just wanted to thank you for your work as it has had a marked impact on my kanji journey over the past 500 days.

7 Likes

That’s what it’s all about, and I’m glad to help make the journey better.

Congratulations on your progress, by the way! It’s been a few years since I was in your shoes, but I remember the feeling of nearing the end. Make sure you do something to celebrate when you reach 60!

5 Likes

Hello, I’m not sure if this is the right place to ask about developing a userscript, so sorry in advance.

I’m trying to implement some changes to Jitai (improved support for webfonts), but I’m having trouble loading and saving my settings. I have the following code in the open settings dialog function:

image

And onSettingsSave is the callback to on_save

image

However, it doesn’t work as expected, in that when I open the dialog it doesn’t retrieve my settings (all checkboxes are unchecked), not does it save them when I close it (checking a checkbox doesn’t mark it as selected). I’m not sure what I’m doing wrong here, so any help would be really useful!

I’m also not sure how to clear my settings to test on a “blank slate”, so any help in that direction would be really useful as well.

The full code is on this file: Jitai/Jitai.user.js at main · jrom99/Jitai · GitHub

Since you are using the dialog, when you tell it what to use for the on_save property, this function is for actions to perform after the settings have been saved but before the dialog closes, but you do not normally manually call wkof.Settings.save() inside this; the dialog has already saved. So I think if you were to just change onSettingsUpdate() to onSettingsSave() and get rid of the current one, it would work as expected.

If not, I’ll have to look a bit harder at your code to figure out what’s wrong…

Edit: this is a secondary guess at what the issue could be, but I believe your defaults object passed when you do loadSettings needs to match the structure of the content property of the config in openSettings. Currently your defaults has only the property fonts, but your config has a content property with an object that does not have a fonts property at all.

Is this necessary? I currently have the path property set to @fonts[fontId].useFont (since I plan to use a more complex settings layout later).

So, I’m doing this silly cursed method of adjusting the available settings for a different option when a separate option is changed, but based on the phrasing of the Open Framework documentation it seems like there should be a native way to handle this.
Can anyone enlighten me on what I’m overlooking?

function onPrimarySortOptionChanged(name, value) {
    // TODO: This method is a cursed way of handling this and should be replaced by a natively available method via WKOF if/when I can figure one out.
    const options = this.parentNode.parentNode.nextSibling.lastChild.lastChild.options,
        allKeys = Object.keys(sortingMethods),
        missingSortingMethods = getMissingSortingMethods(options);
    for (let i = 0; i < missingSortingMethods.length; i++) {
        const [missingName, missingValue] = missingSortingMethods[i],
            insertBefore = allKeys.indexOf(missingName),
            newOption = document.createElement("option");
        newOption.setAttribute('name', missingName);
        newOption.text = missingValue;
        options.add(newOption, insertBefore);
    }
    switch (value) {
        case 'category':
            break;
        case 'source':
            options.remove(options.namedItem('category').index);
            break;
        case 'shortness':
            options.remove(options.namedItem('longness').index);
            break;
        case 'longness':
            options.remove(options.namedItem('shortness').index);
            break;
        default: // also handles "default"
            options.remove(options.namedItem('category').index);
            options.remove(options.namedItem('source').index);
            options.remove(options.namedItem('longness').index);
            options.remove(options.namedItem('shortness').index);
            return;
    }
    options.remove(options.namedItem(value).index);
}

The whole code isn’t important, I’m just including the entire function for context. The main problem is that I’m literally modifying the DOM directly.

I thought there would be a way to handle that in on_change based on this wording:

  • on_change() - (optional) This callback is called after the user modifies a setting in the dialog and the setting has passed validation (if any). The intent is to allow you to immediately act upon a change while the dialog is still open, including dynamically modifying other settings in the dialog (e.g. enabling/disabling other fields).Parameters: change_callback(name, value, config)
    • name - The name of the configuration sub-object for the changed setting. For example, given a setting max_height: {type:"number", label:"Maximum Height", default:10}, the name is "max_height".
    • value - The new value of the setting.
    • config - The configuration sub-object for the changed settings. For example, {type:"number", label:"Maximum Height", default:10}.

However, even with a reference to the settings dialog, i.e.:

const dialog = new wkof.Settings(settingsConfig);

I was unable to figure out the right way to go about achieving it.

WKOF does have some features that can assist in updating settings that are based on other settings, such as changing which item is selected in a related setting. But, unfortunately, it doesn’t currently support changing the available options in a list or dropdown.

If all you needed to do was change which items were selected, you would handle it like this:

  1. In your dialog configuration, for the setting that affects other settings:
    a. Set “refresh_on_change: true” for the item that affects other items.
    b. Set an “on_change” event handler for the item that affects other items.
  2. In the on_change function, change the values of the related settings in wkof.settings[your_script]

When a change occurs, your on_change handler gets called first, and then a refresh occurs, which reloads the values from wkof.settings[your_script], including your pending changes, and sets the values of those items in the Settings dialog accordingly.

Thank you for the detailed response. Then it’s as I feared: that you can change the current value of another setting but not its content.

Am I reading into it too much to take it that you’re open to the idea of such a feature in the future if the demand was right? (Semi-rhetorical question)

Just to solidify my awareness of what’s possible, I opened up the debugger to see what’s going on with the creation and opening of the dialog. I think I appreciate the situation a bit better now; with the HTML being created the way it is, it’s not like you could simply return an object from open() that could be modified and have the modifications seamlessly propagate to the rendered dialog (at least not without tremendous work).
To be clear, I intend that more of as a personal complaint on JavaScript/HTML itself than a criticism of the WKOF implementation.

On the plus side, I did now notice that you give ID attributes to all of the settings, so that at least means that I can very easily querySelector it that way instead of traversing the parentNode, nextSibling, and lastChild elements. It’s ever-so-slightly less cursed now haha.

It’s more a matter of time than demand. I really enjoy developing the various scripts, but I’ve been swamped with work for a long time (which, as a contractor, is a good thing). I’m dropping back on my hours after July, but that means I’ll be catching up on home projects for quite a while.

The Settings module was built in parallel with Self-Study Quiz, so it is mostly designed to meet the needs of that script, plus a few things that I thought others might find useful. For the initial design, I wanted it to be easy for the majority of users to create simple dialogs, and I wanted to leave it open-ended for the more advanced scripters to be able to design complex dialogs in whatever way made sense for their scripts. I still think that was generally the right way to go, because I’ve seen some surprisingly complex dialogs from a few of the advanced scripters – things that I could not have anticipated early on – but there are definitely a few features that I can see being added as built-ins, and the ability to change the choices in a list or dropdown is one of those. So, the next time I work on the Settings module, that will be high on my list.

6 Likes

@rfindley finally taking a look at what you said here about adding WK Custom SRS as a WKOF source, and was just hoping you might be able to clarify slightly how the fetcher function should work.

So far I have this:

if(wkof) {
    const wkofHandler = (config, options) => {
        return new Promise((resolve, reject) => {
            reject("Not implemented yet.");
        });
    }
    wkof.ItemData.registry.sources["wk_custom_srs"] = {
       description: "WK Custom SRS",
       fetcher: wkofHandler
    }
}

I can see from the WKOF implementation

WKOF Implementation
function get_wk_items(config, options) {
		let cfg_options = config.options || {};
		options = options || {};
		let now = new Date().getTime();

		// Endpoints that we can fetch (subjects MUST BE FIRST!!)
		let available_endpoints = ['subjects','assignments','review_statistics','study_materials'];
		let spec = wkof.ItemData.registry.sources.wk_items;
		for (let filter_name in config.filters) {
			let filter_spec = spec.filters[filter_name];
			if (!filter_spec || typeof filter_spec.set_options !== 'function') continue;
			let filter_cfg = config.filters[filter_name];
			filter_spec.set_options(cfg_options, filter_cfg.value);
		}

		// Fetch all of the endpoints
		let ep_promises = [];
		for (let idx in available_endpoints) {
			let ep_name = available_endpoints[idx];
			if (ep_name === 'subjects' || cfg_options[ep_name] === true)
				ep_promises.push(
					wkof.Apiv2.get_endpoint(ep_name, options)
					.then(process_data.bind(null, ep_name))
				);
		}
		return Promise.all(ep_promises)
		.then(function(all_data){
			return all_data[0];
		});

		//============
		function process_data(ep_name, ep_data) {
			if (ep_name === 'subjects') return ep_data;
			// Merge with 'subjects' when 'subjects' is done fetching.
			return ep_promises[0].then(cross_link.bind(null, ep_name, ep_data));
		}

		//============
		function cross_link(ep_name, ep_data, subjects) {
			for (let id in ep_data) {
				let record = ep_data[id];
				let subject_id = record.data.subject_id;
				subjects[subject_id][ep_name] = record.data;
			}
		}
	}

that it seemingly needs to handle all the endpoints? Could I e.g. only handle the subjects/id endpoint? @Kumirei my main intention is to get the script working with your Heatmap - what function/arguments etc. would it try to call in the WKOF get_items() which I need to implement in my custom source fetcher?

I’d like to get my Custom SRS script working with at least Heatmap, but also can’t spend ages working on replicating all the endpoints etc. the WK handler manages, so I’m just trying to work out what the minimum necessary would be :sweat_smile:

Sorry for all the questions, I’d love to get this working but unfortunately just have limited time to work out how to so any help would be amazing :upside_down_face:

1 Like

Attempting to help.

Heatmap uses the Review Cache library script. The data for that is an array of [timestamp, id, srs_level, meaning incorrect, reading incorrect]. You can look at the code either here on greasyfork or in the repo: Wanikani > Review Cache.

As for registering your own source, it looks like the implementation of the fetcher function is completely up to you aside from it potentially needing to handle a config object with two properties (options and filters, both of which you will define how they need to be structured) and the global_options argument. “Potentially” because these are both optional. Your fetcher function could make no use of them at all. But I can say that your fetcher does not need to handle the Wanikani endpoints at all. The relevant function is actually above the one you pasted, get_items() not get_wk_items(), and in that you can see how it goes through all of the available sources, which will include wk_items if it was included in the config. Note that calling get_items() with no config only fetches the Wanikani data.

2 Likes