Wanikani Open Framework

Framework for writing scripts for Wanikani

// ==UserScript==
// @name        Wanikani Open Framework
// @namespace   rfindley
// @description Framework for writing scripts for Wanikani
// @version     1.2.9
// @match       https://www.wanikani.com/*
// @match       https://preview.wanikani.com/*
// @copyright   2018-2024, Robin Findley
// @license     MIT; http://opensource.org/licenses/MIT
// @run-at      document-start
// @grant       none
// ==/UserScript==

(function(global) {
	'use strict';

	/* eslint no-multi-spaces: off */
	/* globals wkof */

	const version = '1.2.9';
	let ignore_missing_indexeddb = false;

	//########################################################################
	//------------------------------
	// Supported Modules
	//------------------------------
	const supported_modules = {
		Apiv2:    { url: 'https://update.greasyfork.org/scripts/38581/1402158/Wanikani%20Open%20Framework%20-%20Apiv2%20module.js'},
		ItemData: { url: 'https://update.greasyfork.org/scripts/38580/1187212/Wanikani%20Open%20Framework%20-%20ItemData%20module.js'},
		Jquery:   { url: 'https://update.greasyfork.org/scripts/451078/1091794/Wanikani%20Open%20Framework%20-%20Jquery%20module.js'},
		Menu:     { url: 'https://update.greasyfork.org/scripts/38578/1489081/Wanikani%20Open%20Framework%20-%20Menu%20module.js'},
		Progress: { url: 'https://update.greasyfork.org/scripts/38577/1091792/Wanikani%20Open%20Framework%20-%20Progress%20module.js'},
		Settings: { url: 'https://update.greasyfork.org/scripts/38576/1091793/Wanikani%20Open%20Framework%20-%20Settings%20module.js'},
	};

	//########################################################################
	//------------------------------
	// Published interface
	//------------------------------
	const published_interface = {
		on_pageload: on_pageload,      // on_pageload(urls, load_handler [, unload_handler])

		include: include,              // include(module_list)        => Promise
		ready:   ready,                // ready(module_list)          => Promise

		load_file:   load_file,        // load_file(url, use_cache)   => Promise
		load_css:    load_css,         // load_css(url, use_cache)    => Promise
		load_script: load_script,      // load_script(url, use_cache) => Promise

		file_cache: {
			dir:    {},                // Object containing directory of files.
			ls:     file_cache_list,   // ls()
			clear:  file_cache_clear,  // clear()             => Promise
			delete: file_cache_delete, // delete(name)        => Promise
			flush:  file_cache_flush,  // flush()             => Promise
			load:   file_cache_load,   // load(name)          => Promise
			save:   file_cache_save,   // save(name, content) => Promise
			no_cache:file_nocache,     // no_cache(modules)
		},

		on:      wait_event,           // on(event, callback)
		trigger: trigger_event,        // trigger(event[, data1[, data2[, ...]]])

		get_state:  get_state,         // get(state_var)
		set_state:  set_state,         // set(state_var, value)
		wait_state: wait_state,        // wait(state_var, value[, callback[, persistent]]) => if no callback, return one-shot Promise

		version: {
			value: version,
			compare_to: compare_to,    // compare_version(version)
		}
	};

	published_interface.support_files = {
		'jquery.js': 'https://ajax.googleapis.com/ajax/libs/jquery/3.6.1/jquery.min.js',
		'jquery_ui.js': 'https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js',
		'jqui_wkmain.css': 'https://raw.githubusercontent.com/rfindley/wanikani-open-framework/1550af8383ec28ad406cf401aee2de4c52446f6c/jqui-wkmain.css',
	};

	//########################################################################

	function split_list(str) {return str.replace(/、/g,',').replace(/[\s ]+/g,' ').trim().replace(/ *, */g, ',').split(',').filter(function(name) {return (name.length > 0);});}
	function promise(){let a,b,c=new Promise(function(d,e){a=d;b=e;});c.resolve=a;c.reject=b;return c;}
	function is_turbo_page() {return (document.querySelector('script[type="importmap"]')?.innerHTML.match('@hotwired/turbo') != null);}

	//########################################################################

	//------------------------------
	// Compare the framework version against a specific version.
	//------------------------------
	function compare_to(client_version) {
		let client_ver = client_version.split('.').map(d => Number(d));
		let wkof_ver = version.split('.').map(d => Number(d));
		let len = Math.max(client_ver.length, wkof_ver.length);
		for (let idx = 0; idx < len; idx++) {
			let a = client_ver[idx] || 0;
			let b = wkof_ver[idx] || 0;
			if (a === b) continue;
			if (a < b) return 'newer';
			return 'older';
		}
		return 'same';
	}

	//------------------------------
	// Include a list of modules.
	//------------------------------
	let include_promises = {};

	function include(module_list) {
		if (wkof.get_state('wkof.wkof') !== 'ready') {
			return wkof.ready('wkof').then(function(){return wkof.include(module_list);});
		}
		let include_promise = promise();
		let module_names = split_list(module_list);
		let script_cnt = module_names.length;
		if (script_cnt === 0) {
			include_promise.resolve({loaded:[], failed:[]});
			return include_promise;
		}

		let done_cnt = 0;
		let loaded = [], failed = [];
		let no_cache = split_list(localStorage.getItem('wkof.include.nocache') || '');
		for (let idx = 0; idx < module_names.length; idx++) {
			let module_name = module_names[idx];
			let module = supported_modules[module_name];
			if (!module) {
				failed.push({name:module_name, url:undefined});
				check_done();
				continue;
			}
			let await_load = include_promises[module_name];
			let use_cache = (no_cache.indexOf(module_name) < 0) && (no_cache.indexOf('*') < 0);
			if (!use_cache) file_cache_delete(module.url);
			if (await_load === undefined) include_promises[module_name] = await_load = load_script(module.url, use_cache);
			await_load.then(push_loaded, push_failed);
		}

		return include_promise;

		function push_loaded(url) {
			loaded.push(url);
			check_done();
		}

		function push_failed(url) {
			failed.push(url);
			check_done();
		}

		function check_done() {
			if (++done_cnt < script_cnt) return;
			if (failed.length === 0) include_promise.resolve({loaded:loaded, failed:failed});
			else include_promise.reject({error:'Failure loading module', loaded:loaded, failed:failed});
		}
	}

	//------------------------------
	// Wait for all modules to report that they are ready
	//------------------------------
	function ready(module_list) {
		let module_names = split_list(module_list);

		let ready_promises = [ ];
		for (let idx in module_names) {
			let module_name = module_names[idx];
			ready_promises.push(wait_state('wkof.' + module_name, 'ready'));
		}

		if (ready_promises.length === 0) {
			return Promise.resolve();
		} else if (ready_promises.length === 1) {
			return ready_promises[0];
		} else {
			return Promise.all(ready_promises);
		}
	}
	//########################################################################

	//------------------------------
	// Load a file asynchronously, and pass the file as resolved Promise data.
	//------------------------------
	function load_file(url, use_cache) {
		let fetch_promise = promise();
		let no_cache = split_list(localStorage.getItem('wkof.load_file.nocache') || '');
		if (no_cache.indexOf(url) >= 0 || no_cache.indexOf('*') >= 0) use_cache = false;
		if (use_cache === true) {
			return file_cache_load(url, use_cache).catch(fetch_url);
		} else {
			return fetch_url();
		}

		// Retrieve file from server
		function fetch_url(){
			let request = new XMLHttpRequest();
			request.onreadystatechange = process_result;
			request.open('GET', url, true);
			request.send();
			return fetch_promise;
		}

		function process_result(event){
			if (event.target.readyState !== 4) return;
			if (event.target.status >= 400 || event.target.status === 0) return fetch_promise.reject(event.target.status);
			if (use_cache) {
				file_cache_save(url, event.target.response)
				.then(fetch_promise.resolve.bind(null,event.target.response));
			} else {
				fetch_promise.resolve(event.target.response);
			}
		}
	}

	//------------------------------
	// Load and install a specific file type into the DOM.
	//------------------------------
	function load_and_append(url, tag_name, location, use_cache) {
		url = url.replace(/"/g,'\'');
		if (document.querySelector(tag_name+'[uid="'+url+'"]') !== null) return Promise.resolve();
		return load_file(url, use_cache).then(append_to_tag);

		function append_to_tag(content) {
			let tag = document.createElement(tag_name);
			tag.innerHTML = content;
			tag.setAttribute('uid', url);
			document.querySelector(location).appendChild(tag);
			return url;
		}
	}

	//------------------------------
	// Load and install a CSS file.
	//------------------------------
	function load_css(url, use_cache) {
		return load_and_append(url, 'style', 'head', use_cache);
	}

	//------------------------------
	// Load and install Javascript.
	//------------------------------
	function load_script(url, use_cache) {
		return load_and_append(url, 'script', 'head', use_cache);
	}
	//########################################################################

	let state_listeners = {};
	let state_values = {};

	//------------------------------
	// Get the value of a state variable, and notify listeners.
	//------------------------------
	function get_state(state_var) {
		return state_values[state_var];
	}

	//------------------------------
	// Set the value of a state variable, and notify listeners.
	//------------------------------
	function set_state(state_var, value) {
		let old_value = state_values[state_var];
		if (old_value === value) return;
		state_values[state_var] = value;

		// Do listener callbacks, and remove non-persistent listeners
		let listeners = state_listeners[state_var];
		let persistent_listeners = [ ];
		for (let idx in listeners) {
			let listener = listeners[idx];
			let keep = true;
			if (listener.value === value || listener.value === '*') {
				keep = listener.persistent;
				try {
					listener.callback(value, old_value);
				} catch (e) {}
			}
			if (keep) persistent_listeners.push(listener);
		}
		state_listeners[state_var] = persistent_listeners;
	}

	//------------------------------
	// When state of state_var changes to value, call callback.
	// If persistent === true, continue listening for additional state changes
	// If value is '*', callback will be called for all state changes.
	//------------------------------
	function wait_state(state_var, value, callback, persistent) {
		let promise;
		if (callback === undefined) {
			promise = new Promise(function(resolve, reject) {
				callback = resolve;
			});
		}
		if (state_listeners[state_var] === undefined) state_listeners[state_var] = [ ];
		persistent = (persistent === true);
		let current_value = state_values[state_var];
		if (persistent || value !== current_value) state_listeners[state_var].push({callback:callback, persistent:persistent, value:value});

		// If it's already at the desired state, call the callback immediately.
		if (value === current_value) {
			try {
				callback(value, current_value);
			} catch (err) {}
		}
		return promise;
	}
	//########################################################################

	let event_listeners = {};

	//------------------------------
	// Fire an event, which then calls callbacks for any listeners.
	//------------------------------
	function trigger_event(event) {
		let listeners = event_listeners[event];
		if (listeners === undefined) return;
		let args = [];
		Array.prototype.push.apply(args,arguments);
		args.shift();
		for (let idx in listeners) { try {
			listeners[idx].apply(null,args);
		} catch (err) {} }
		return global.wkof;
	}

	//------------------------------
	// Add a listener for an event.
	//------------------------------
	function wait_event(event, callback) {
		if (event_listeners[event] === undefined) event_listeners[event] = [];
		event_listeners[event].push(callback);
		return global.wkof;
	}

	//------------------------------
	// Add handlers for page load events for a list of URLs.
	//------------------------------
	let regex_store = {};
	let pgld_req_store = [];
	let current_page = '!';
	function on_pageload(url_patterns, load_handler, unload_handler) {
		if (!Array.isArray(url_patterns)) url_patterns = [url_patterns];

		let pgld_req = {url_patterns, load_handler, unload_handler, pattern_idx:-1, next_pattern_idx:-1};
		pgld_req_store.push(pgld_req);

		url_patterns.forEach((pattern, pattern_idx) => {
			let regex;
			if (typeof pattern === 'string') {
				regex = new RegExp('^'+pattern.replace(/[.+?^${}()|[\]\\]/g,'\\$&').replaceAll('*','.*')+'$');
			} else if (pattern instanceof RegExp) {
				regex = pattern;
			} else {
				pgld_req.regexes.push(null);
				return;
			}

			let regex_str = regex.toString();
			let regex_entry;
			if (!regex_store[regex_str]) {
				regex_store[regex_str] = regex_entry = {regex, pgld_reqs:[]};
			} else {
				regex_entry = regex_store[regex_str];
				regex = regex_entry.regex;
			}
			regex_entry.pgld_reqs.push({pgld_req, pattern_idx});

			// Call 'load' callback now if the current URL matches.
			if (pgld_req.pattern_idx !== -1) return;
			if (regex.test(current_page)) {
				pgld_req.pattern_idx = pattern_idx;
				try {
					load_handler(current_page, pattern_idx);
				} catch(e) {}
			}
		});
	}

	//------------------------------
	// Call pageload handlers.
	//------------------------------
	function handle_pageload(event) {
		let last_page = current_page;
		if (event) {
			current_page = (new URL(event.detail.url)).pathname;
		} else {
			current_page = window.location.pathname;
		}

		// Update the active status of all monitored URL patterns.
		Object.keys(regex_store).forEach((key, key_idx) => {
			let regex_entry = regex_store[key];
			let is_active = regex_entry.regex.test(current_page);
			regex_entry.pgld_reqs.forEach((regex_pgld_entry, regex_pgld_entry_idx) => {
				if (regex_pgld_entry.pgld_req.next_pattern_idx === -1 && is_active) {
					regex_pgld_entry.pgld_req.next_pattern_idx = regex_pgld_entry.pattern_idx;
				}
			});
		});

		// Call all 'unload' handlers.
		pgld_req_store.forEach(pgld_req => {
			// If page was active, but not anymore, call the unload handler
			if ((pgld_req.pattern_idx !== -1) && (pgld_req.next_pattern_idx === -1)) {
				try {
					pgld_req.unload_handler(last_page, pgld_req.pattern_idx);
				} catch(e) {}
			}
			pgld_req.pattern_idx = pgld_req.next_pattern_idx;
			pgld_req.next_pattern_idx = -1;
		});

		// Call all 'load' handlers.
		pgld_req_store.forEach(pgld_req => {
			if (pgld_req.pattern_idx !== -1) {
				try {
					pgld_req.load_handler(current_page, pgld_req.pattern_idx);
				} catch(e) {}
			}
		});
	}

	let first_pageload = true;
	let skip_next_turbo_load = false;
	function delayed_pageload(event) {
		if (!event) { // If 'doc ready'
			if (!first_pageload) return; // Shouldn't happen, but just in case...
			skip_next_turbo_load = true;
		} else { // If 'turbo:load'
			if (skip_next_turbo_load) {
				skip_next_turbo_load = false;
				return;
			}
		}
		first_pageload = false;
		setTimeout(handle_pageload.bind(null, event), 10);
	}

	//########################################################################

	let file_cache_open_promise;

	//------------------------------
	// Open the file_cache database (or return handle if open).
	//------------------------------
	function file_cache_open() {
		if (file_cache_open_promise) return file_cache_open_promise;
		let open_promise = promise();
		file_cache_open_promise = open_promise;
		let request;
		request = indexedDB.open('wkof.file_cache');
		request.onupgradeneeded = upgrade_db;
		request.onsuccess = get_dir;
		request.onerror = error;
		return open_promise;

		function error() {
			console.log('indexedDB could not open!');
			wkof.file_cache.dir = {};
			if (ignore_missing_indexeddb) {
				open_promise.resolve(null);
			} else {
				open_promise.reject();
			}
		}

		function upgrade_db(event){
			let db = event.target.result;
			let store = db.createObjectStore('files', {keyPath:'name'});
		}

		function get_dir(event){
			let db = event.target.result;
			let transaction = db.transaction('files', 'readonly');
			let store = transaction.objectStore('files');
			let request = store.get('[dir]');
			request.onsuccess = process_dir;
			transaction.oncomplete = open_promise.resolve.bind(null, db);
			open_promise.then(setTimeout.bind(null, file_cache_cleanup, 10000));
		}

		function process_dir(event){
			if (event.target.result === undefined) {
				wkof.file_cache.dir = {};
			} else {
				wkof.file_cache.dir = JSON.parse(event.target.result.content);
			}
		}
	}

	//------------------------------
	// Lists the content of the file_cache.
	//------------------------------
	function file_cache_list() {
		console.log(Object.keys(wkof.file_cache.dir).sort().join('\n'));
	}

	//------------------------------
	// Clear the file_cache database.
	//------------------------------
	function file_cache_clear() {
		return file_cache_open().then(clear);

		function clear(db) {
			let clear_promise = promise();
			wkof.file_cache.dir = {};
			if (db === null) return clear_promise.resolve();
			let transaction = db.transaction('files', 'readwrite');
			let store = transaction.objectStore('files');
			store.clear();
			transaction.oncomplete = clear_promise.resolve;
		}
	}

	//------------------------------
	// Delete a file from the file_cache database.
	//------------------------------
	function file_cache_delete(pattern) {
		return file_cache_open().then(del);

		function del(db) {
			let del_promise = promise();
			if (db === null) return del_promise.resolve();
			let transaction = db.transaction('files', 'readwrite');
			let store = transaction.objectStore('files');
			let files = Object.keys(wkof.file_cache.dir).filter(function(file){
				if (pattern instanceof RegExp) {
					return file.match(pattern) !== null;
				} else {
					return (file === pattern);
				}
			});
			files.forEach(function(file){
				store.delete(file);
				delete wkof.file_cache.dir[file];
			});
			file_cache_dir_save();
			transaction.oncomplete = del_promise.resolve.bind(null, files);
			return del_promise;
		}
	}

	//------------------------------
	// Force immediate save of file_cache directory.
	//------------------------------
	function file_cache_flush() {
		file_cache_dir_save(true /* immediately */);
	}

	//------------------------------
	// Load a file from the file_cache database.
	//------------------------------
	function file_cache_load(name) {
		let load_promise = promise();
		return file_cache_open().then(load);

		function load(db) {
			if (wkof.file_cache.dir[name] === undefined) {
				load_promise.reject(name);
				return load_promise;
			}
			let transaction = db.transaction('files', 'readonly');
			let store = transaction.objectStore('files');
			let request = store.get(name);
			wkof.file_cache.dir[name].last_loaded = new Date().toISOString();
			file_cache_dir_save();
			request.onsuccess = finish;
			request.onerror = error;
			return load_promise;

			function finish(event){
				if (event.target.result === undefined || event.target.result === null) {
					load_promise.reject(name);
				} else {
					load_promise.resolve(event.target.result.content);
				}
			}

			function error(event){
				load_promise.reject(name);
			}
		}
	}

	//------------------------------
	// Save a file into the file_cache database.
	//------------------------------
	function file_cache_save(name, content, extra_attribs) {
		return file_cache_open().then(save);

		function save(db) {
			let save_promise = promise();
			if (db === null) return save_promise.resolve(name);
			let transaction = db.transaction('files', 'readwrite');
			let store = transaction.objectStore('files');
			store.put({name:name,content:content});
			let now = new Date().toISOString();
			wkof.file_cache.dir[name] = Object.assign({added:now, last_loaded:now}, extra_attribs);
			file_cache_dir_save(true /* immediately */);
			transaction.oncomplete = save_promise.resolve.bind(null, name);
		}
	}

	//------------------------------
	// Save a the file_cache directory contents.
	//------------------------------
	let fc_sync_timer;
	function file_cache_dir_save(immediately) {
		if (fc_sync_timer !== undefined) clearTimeout(fc_sync_timer);
		let delay = (immediately ? 0 : 2000);
		fc_sync_timer = setTimeout(save, delay);

		function save(){
			file_cache_open().then(save2);
		}

		function save2(db){
			fc_sync_timer = undefined;
			let transaction = db.transaction('files', 'readwrite');
			let store = transaction.objectStore('files');
			store.put({name:'[dir]',content:JSON.stringify(wkof.file_cache.dir)});
		}
	}

	//------------------------------
	// Remove files that haven't been accessed in a while.
	//------------------------------
	function file_cache_cleanup() {
		let threshold = new Date() - 14*86400000; // 14 days
		let old_files = [];
		for (var fname in wkof.file_cache.dir) {
			if (fname.match(/^wkof\.settings\./)) continue; // Don't flush settings files.
			let fdate = new Date(wkof.file_cache.dir[fname].last_loaded);
			if (fdate < threshold) old_files.push(fname);
		}
		if (old_files.length === 0) return;
		console.log('Cleaning out '+old_files.length+' old file(s) from "wkof.file_cache":');
		for (let fnum in old_files) {
			console.log('  '+(Number(fnum)+1)+': '+old_files[fnum]);
			wkof.file_cache.delete(old_files[fnum]);
		}
	}

	//------------------------------
	// Process no-cache requests.
	//------------------------------
	function file_nocache(list) {
		if (list === undefined) {
			list = split_list(localStorage.getItem('wkof.include.nocache') || '');
			list = list.concat(split_list(localStorage.getItem('wkof.load_file.nocache') || ''));
			console.log(list.join(','));
		} else if (typeof list === 'string') {
			let no_cache = split_list(list);
			let idx, modules = [], urls = [];
			for (idx = 0; idx < no_cache.length; idx++) {
				let item = no_cache[idx];
				if (supported_modules[item] !== undefined) {
					modules.push(item);
				} else {
					urls.push(item);
				}
			}
			console.log('Modules: '+modules.join(','));
			console.log('   URLs: '+urls.join(','));
			localStorage.setItem('wkof.include.nocache', modules.join(','));
			localStorage.setItem('wkof.load_file.nocache', urls.join(','));
		}
	}

	function doc_ready() {
		wkof.set_state('wkof.document', 'ready');
	}

	//########################################################################
	// Bootloader Startup
	//------------------------------
	function startup() {
		global.wkof = published_interface;

		// Handle page-loading/unloading events.
		function install_load_listener() {
			if (!document.documentElement) {
				setTimeout(install_load_listener, 10);
				return;
			}
			document.documentElement.addEventListener('turbo:load', delayed_pageload);
		}
		install_load_listener();

		ready('document').then((e) => {
			if (first_pageload) delayed_pageload();
		});

		// Mark document state as 'ready'.
		if (document.readyState === 'complete') {
			doc_ready();
		} else {
			window.addEventListener("load", doc_ready, false);  // Notify listeners that we are ready.
		}

		// Open cache, so wkof.file_cache.dir is available to console immediately.
		file_cache_open();
		wkof.set_state('wkof.wkof', 'ready');
	}
	startup();

})(window);