Wanikani Open Framework [developer thread]

I would love this.
Would remove a lot of bloat, and I like having a common framework to build upon.

Is there a git repository of this somewhere, or are you just using Greasyfork directly?

It’s currently just on my dev machine because I’m still making a lot of big design decisions. But if people want a really early look, I can throw something on github soon. A lot of it is still just snippets from various experiments.

I only put things on greasyfork when they’re ready for release.

I’ve been pondering the framework’s Settings module.

I had originally considered putting settings on a separate URL, similar to Wanikani’s built-in settings pages. That might work for some scripts, but other scripts really need quick access to settings without leaving the page… for example, my Self-Study Quiz script, where the settings allow you to configure quiz material.

So, I decided I definitely need some sort of pop-up settings dialog. Here’s an early sample:

.

I welcome any input from coders and graphic designers.

For you coders, here’s the (preliminary) code to create the above dialog box:

Code (click to open)
var settings = {
	'grp_time': {
		type: 'group',
		label: 'Time Axis',
		content: {
			'absrel': {
				type:'dropdown',
				label:'Absolute/Relative',
				content:{'abs':'Absolute','rel':'Relative'}
			},
			'hours24': {
				type: 'dropdown',
				label:'24 Hour',
				content:{'12': '12-hour','24': '24-hour'}
			},
			'scale_redraw': {
				type:'dropdown',
				label:'Redraw While Scaling',
				content:{'yes':'Yes','no':'No'},
				default:'yes'
			}
		}
	},
	'grp_detail': {
		type: 'group',
		label: 'Review Details',
		content: {
			'show_detail': {
				type:'dropdown',
				label:'Show Review Details',
				content:{'yes':'Yes','no':'No'}
			}
		}
	},
	'grp_graph': {
		type: 'group',
		label: 'Graph',
		content: {
			'bar_style': {
				type:'dropdown',
				label:'Bar Breakdown',
				content:{'rkv':'Rad+Kan+Voc','srs':'SRS Levels','sum':'Summary Only'},
				default:'rkv'
			},
			'special_bars': {
				type:'dropdown',
				label:'Special Bars',
				content:{'none':'None','curr':'Current Level','burn':'Burn Items','both':'Both'},
				default:'both'
			},
			'markers': {
				type:'dropdown',
				label:'<i>Current Level</i> Markers',
				content:{'none':'None','rk':'Rad+Kan','rkv':'Rad+Kan+Voc'},
				default:'rkv'
			},
			'graph_height':{
				type:'integer',
				label:'Graph Height (in pixels)',
				default:100
			},
			'max_days':{
				type:'integer',
				label:'Slider Range Max (days)',
				default:7
			}
		}
	},
};
wkof.Settings.install('wk_timeln', 'Ultimate Timeline', settings);

I’m also planning to support something like a tab bar to allow multiple pages of grouped settings.

Any thoughts so far?

Look at you, putting my settings popup to shame :stuck_out_tongue:

Thanks for the inspiration :slight_smile:

I’ve added tab support to the Settings module:

I’ve also made it more responsive to resizing:

Feedback, anyone? I definitely want this to be something other script writers will want to use.

The json structure for tabs/pages is:

Code (click to open)
var settings = {
	'pg_graph': {
		type: 'page',
		label: 'Graph',
		content: {
			...
		}
	},
	'pg_detail': {
		type: 'page',
		label: 'Detail View',
		content: {
			...
		}
	}
};

Added color-picking to Settings module:
image

It uses the browser’s built-in colorpicker. (Chrome shown below):
image

Code (click to open)
var settings = {
	'clr_burn': {
		type:'color',
		label:'Burned Items Bar/Arrow',
		default:'#000000'
	}
}

[Note: iOS Safari doesn’t support HTML5 input[type=“color”]. If there’s anyone actually using userscripts on iOS Safari, I’ll consider adding [Spectrum Colorpicker] as a polyfill. It’s the same colorpicker used in Chrome, Firefox, and Safari devtools.]

Does ios safari even have a way of installing userscripts?

https://greasyfork.org/en
yes.

I don’t see ios safari in that list, just safari (which usually means desktop)

oh ok, thought there is only one safari :stuck_out_tongue:

How are you storing the settings? I was not a fan of my different item in jstorage for each setting, wanting to convert to json object, so each script only had one stored object, but never did. Looks like that’s where you’re probably headed with the structure you’re showing off.
Also, safeguards so that two scripts don’t end up with the same named setting?
Judging by what you’ve done so far though, I’m sure you’ve taken these into account already.

@DaisukeJigen,
Scripts that use the framework will register a unique name (e.g. ‘ultimate_timeline’), which is used to keep various things isolated (events, settings, DOM ids, etc).

There will be one top-level object (wkof.settings), with a sub-object for each script (wkof.settings.ultimate_timeline) containing all settings for that script:

wkof.settings.ultimate_timeline = {
  absrel: 'abs',
  hours24: '12',
  scale_redraw: 'yes',
  ...  
}

By using one top-level object, users can choose to install a ‘sync’ script that can sync settings for all scripts to a 3rd-party location without having to understand the structure and content of the settings object.

For now, I’m storing the settings object in localStorage, but I may move it to indexeddb. It shouldn’t matter as long as people use the framework to load/save(/sync). There will be functions for each, easily typed from the console.

Scripts can also request to be notified under various circumstances:

  • When individual settings change while the user is still in the settings dialog (e.g. for interactive changes, such as editing theme colors)
  • When the user clicks ‘Save’ on the settings screen (only if changes were made)
  • When any setting (from any script) changes, for use by plugins like ‘sync’.

Any thoughts on making an easy way to do validation, or at least dependencies? ‘x options only available when y, and if not y then set back to default’, ‘only numeric values accepted’, etc.
I imagine the fields will be named nicely, ‘txt’ + itemName, or whatever. So having to write outside the settings object isn’t too bad, as long as we get an indicator when form is fully loaded and populated.

Short answer

[edited]
See the next post below for comments about validation.

For dependencies, you can use the callback for interactive changes, then manipulate the DOM yourself. Hopefully, the vanilla module will cover 80% of use-cases, and the callbacks will cover the rest.

Naming convention

The dialog elements will be primarily based on the name you pass in to the settings configuration. For example, given the following configuration line:

	'scale_redraw': {
		type:'dropdown',
		label:'Redraw While Scaling',
		content:{'yes':'Yes','no':'No'},
		default:'yes'
	}

Assuming I’ve registered my script as ‘ultimate_timeline’, the element ID will be accessible via something like:

	$('#ultimate_timeline_scale_redraw')

It’s long, but it keeps it simple and avoids collisions.

Accessing the datapoint

That same datapoint will then show up in the settings object as:
wkof.settings.ultimate_timeline.scale_redraw

Of course, you can shortcut the path:

	var s = wkof.settings.ultimate_timeline;
	console.log(s.scale_redraw);

Also, regarding validation, I’m currently thinking the following:

Validation callback

All items can add a validation function. Invalid values will be colored with a reddish background.

	'graph_height': {
		type: 'integer',
		label: 'Graph Height (in pixels)',
		default: 100,
		validate: check_graph_height
	},

I’m currently thinking the validation function will be able to return a simple true/false value, or an optional object like this:

	return {
		result: false,
		msg: 'Expecting value between 75 and 200'
	};

Integer min/max

For integers, I may also support a min/max:

	'graph_height': {
		type: 'integer',
		label: 'Graph Height (in pixels)',
		default: 100,
		min: 75,
		max: 200
	},

Text regex

For text strings, I will probably also support a regex:

	'apiv1_key': {
		type: 'text',
		label: 'APIv1 key',
		match: /^[0-9a-f]{32}$/
	},

The Settings module is almost done.


Change Events (on_change property)

All settings accept an on_change property:

	dialog = new wkof.Settings('timeln', 'Ultimate Timeline', {
		//...
		'graph_height': {
			type: 'number',
			label: 'Graph Height (in pixels)',
			default: 100,
			min: 60,
			on_change: graph_height_changed
		}
		//...
	});

	function graph_height_changed(value, config) {
		// Redraw the graph immediately, even while
		// the settings dialog is still open.
	}

Validation (validate property)

All settings accept a validate property:

	dialog = new wkof.Settings('timeln', 'Ultimate Timeline', {
		//...
		'max_days': {
			type: 'number',
			label: 'Graph Height (in pixels)',
			default: 7,
			min: 1,
			max: 14,
			validate: validate_max_days
		}
		//...
	});

	function validate_max_days(value, config) {
		// Make sure it's an integer
		if (value !== Math.round(value))
			return 'Must be a whole number of days';
		else
			return true;
	}

Automatic Validation (min, max, and match)

type:"number"
Accepts two optional validation properties, min and max. If the number is not within the specified bounds, an error message is generated automatically:

  • “Must be <min> or higher”
  • “Must be <max> or lower”
  • “Must be between <min> and <max>”

type:"text"
Accepts an optional validation property, match. This can be either a string pattern to match, or a regex object.

	'apiv1_key': {
		type: 'text',
		label: 'APIv1 key',
		match: /^[0-9a-f]{32}$/
	}

Save event (dialog.on_save)

If you only need to respond to events after the dialog box is closed, you can hook into the dialog.on_save property:

	dialog.on_save = settings_changed;

	function settings_changed() {
		// Update based on new settings
		var settings = wkof.settings.timeln;
		$('#graph').height(settings.graph_height);
		// etc...
	}

@DaisukeJigen, @hitechbunny

I think both of you have scripts that install links in the WK menu, and I’d like to avoid collisions with what you already have.

Here’s what I’m picturing for the Open Framework:

image

I made it a separate submenu to avoid crowding out the official WK menu. It is also responsive for small screen sizes.

The code snippet below does the following:

  • Install the Scripts header if not already present
  • Install the Settings submenu if not already present
  • Install a few sample script links

Code (click to open)
//==[ BEGIN UNIVERSAL SECTION ]===============================================
// Although this code is for the Open Framework, any script can include this
// 'Universal Section' to support adding a link to the Wanikani menu in a
// standardized way.

// Install 'Scripts' header in menu, if not present.
function install_scripts_header() {
	// Abort if already installed.
	if ($('.scripts-header').length !== 0) return;

	// Install html.
	$('.nav-header:contains("Account")').before(
		'<li class="scripts-header nav-header">Scripts</li>'
	);
}

// Install 'Settings' menu, if not present.
function install_script_settings_menu() {
	// Abort if already installed.
	if ($('.scripts-settings').length !== 0) return;

	var html =
		'<li class="scripts-settings">'+
		'  <a href="#">Settings</a>'+
		'  <ul class="scripts-settings-menu dropdown-menu">'+
		'  </ul>'+
		'</li>';

	var css =
		'html#main .navbar .scripts-settings {position:relative;}'+
		'html#main .navbar .scripts-settings.open>.scripts-settings-menu {display:block;position:absolute;top:0px;}'+
		'@media (max-width: 979px) {'+
		'  html#main .navbar .scripts-settings>a {display:none;}'+
		'  html#main .navbar .dropdown-menu>li:not(.nav-header).scripts-settings {display:block;width:100%;}'+
		'  html#main .navbar .scripts-settings>.scripts-settings-menu {display:block;padding:0;margin:0;box-shadow:none;}'+
		'  html#main .navbar .scripts-settings:hover>.scripts-settings-menu {position:relative;top:0px;left:initial;right:initial;}'+
		'  html#main .navbar .dropdown-menu>li:not(.nav-header).scripts-settings>.scripts-settings-submenu>li {width:auto;padding:0 1em;}'+
		'}';

	// Install css and html.
	$('head').append('<style>'+css+'</style>');
	$('.scripts-header').after(html);

	// Click to open Settings menu.
	$('.scripts-settings>a').on('click',function(e){
		$('.scripts-settings').toggleClass('open');
		var menu = $('.scripts-settings-menu');
		// If we opened the menu, listen for off-menu clicks.
		if ($('.scripts-settings').hasClass('open')) {
			$('body').on('click.scripts-settings',function(e){
				$('body').off('click.scripts-settings');
				$('.scripts-settings').removeClass('open');
				return true;
			})
		}
		return false;
	});
}

// Inserts script link into script settings menu.
function insert_script(script_id, title, callback, classes) {
	// Abort if the script already exists
	var link_id = script_id+'_settings_link'; 
	if ($('#'+link_id).length !== 0) return;

	// Append the script, and sort the menu.
	var menu = $('.scripts-settings-menu');
	var class_html = (typeof classes === 'string' ? ' class="'+classes+'"': '');
	menu.append('<li id="'+link_id+'" name="'+script_id+'"'+class_html+'><a href="#">'+title+'</a></li>');
	var children = menu.children().sort(function(a,b){
		return a.innerText.localeCompare(b.innerText);
	});
	menu.append(children);

	// Add a callback for when the link is clicked.
	$('#'+link_id).on('click', function(e){
		$('body').off('click.scripts-settings');
		$('.dropdown.account').removeClass('open');
		$('.scripts-settings').removeClass('open');
		callback(e);
		return false;
	});
} 
//==[ END UNIVERSAL SECTION ]=================================================

// Dummy callback to handle link clicks.
function open_settings(e){
	// TODO: Open settings dialog, or go to settings URL.
	console.log(e.target.parentNode.attributes.name.value+': Opening settings...');
}

// Insert some sample scripts.
install_scripts_header();
install_script_settings_menu();
insert_script('timeln'  , 'Ultimate Timeline'           , open_settings, 'wkof');
insert_script('burnmgr' , 'Burn Manager'                , open_settings, 'wkof');
insert_script('dpp'     , 'Dashboard Progress Plus'     , open_settings, 'wkof');
insert_script('example1', 'Example Non-framework Script', open_settings);

Looks like currently we don’t step on each others toes, exactly. The menu will just have two scripts items, until I switch over to open framework of course. It will look a little dumb, but should work fine.

Same here. I don’t think it’s all that important in the App Store case. It’s appropriate enough for it to be under a Scripts heading. Thanks for checking, @rfindley.