Framework for writing scripts for Wanikani
// ==UserScript== // @name Wanikani Open Framework // @namespace rfindley // @description Framework for writing scripts for Wanikani // @version 1.2.10 // @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.10'; 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;} //######################################################################## //------------------------------ // 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_r###lt; request.open('GET', url, true); request.send(); return fetch_promise; } function process_r###lt(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 { 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_turbo_load = null; function delayed_pageload(event) { if (!event) { // If 'doc ready' if (!first_pageload) return; // Shouldn't happen, but just in case... skip_turbo_load = location.href.replace(/#.*/g,''); } else { // If 'turbo:load' if (skip_turbo_load === event.detail.url) { skip_turbo_load = null; return; } skip_turbo_load = null; } 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.r###lt; let store = db.createObjectStore('files', {keyPath:'name'}); } function get_dir(event){ let db = event.target.r###lt; 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.r###lt === undefined) { wkof.file_cache.dir = {}; } else { wkof.file_cache.dir = JSON.parse(event.target.r###lt.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.r###lt === undefined || event.target.r###lt === null) { load_promise.reject(name); } else { load_promise.resolve(event.target.r###lt.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);