Wanikani Open Framework

Framework for writing scripts for Wanikani

目前为 2023-06-30 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Wanikani Open Framework
  3. // @namespace rfindley
  4. // @description Framework for writing scripts for Wanikani
  5. // @version 1.1.5
  6. // @match https://www.wanikani.com/*
  7. // @match https://preview.wanikani.com/*
  8. // @copyright 2022+, Robin Findley
  9. // @license MIT; http://opensource.org/licenses/MIT
  10. // @run-at document-start
  11. // @grant none
  12. // ==/UserScript==
  13.  
  14. (function(global) {
  15. 'use strict';
  16.  
  17. /* eslint no-multi-spaces: off */
  18. /* globals wkof */
  19.  
  20. const version = '1.1.4';
  21. let ignore_missing_indexeddb = false;
  22.  
  23. //########################################################################
  24. //------------------------------
  25. // Supported Modules
  26. //------------------------------
  27. const supported_modules = {
  28. Apiv2: { url: 'https://greasyfork.org/scripts/38581-wanikani-open-framework-apiv2-module/code/Wanikani%20Open%20Framework%20-%20Apiv2%20module.js?version=1180673'},
  29. ItemData: { url: 'https://greasyfork.org/scripts/38580-wanikani-open-framework-itemdata-module/code/Wanikani%20Open%20Framework%20-%20ItemData%20module.js?version=1187212'},
  30. Jquery: { url: 'https://greasyfork.org/scripts/451078-wanikani-open-framework-jquery-module/code/Wanikani%20Open%20Framework%20-%20Jquery%20module.js?version=1091794'},
  31. Menu: { url: 'https://greasyfork.org/scripts/38578-wanikani-open-framework-menu-module/code/Wanikani%20Open%20Framework%20-%20Menu%20module.js?version=1213065'},
  32. Progress: { url: 'https://greasyfork.org/scripts/38577-wanikani-open-framework-progress-module/code/Wanikani%20Open%20Framework%20-%20Progress%20module.js?version=1091792'},
  33. Settings: { url: 'https://greasyfork.org/scripts/38576-wanikani-open-framework-settings-module/code/Wanikani%20Open%20Framework%20-%20Settings%20module.js?version=1091793'},
  34. };
  35.  
  36. //########################################################################
  37. //------------------------------
  38. // Published interface
  39. //------------------------------
  40. const published_interface = {
  41. on_page_event: on_page_event, // on_pages({urls:[], load:func, unload:func})
  42.  
  43. include: include, // include(module_list) => Promise
  44. ready: ready, // ready(module_list) => Promise
  45.  
  46. load_file: load_file, // load_file(url, use_cache) => Promise
  47. load_css: load_css, // load_css(url, use_cache) => Promise
  48. load_script: load_script, // load_script(url, use_cache) => Promise
  49.  
  50. file_cache: {
  51. dir: {}, // Object containing directory of files.
  52. ls: file_cache_list, // ls()
  53. clear: file_cache_clear, // clear() => Promise
  54. delete: file_cache_delete, // delete(name) => Promise
  55. flush: file_cache_flush, // flush() => Promise
  56. load: file_cache_load, // load(name) => Promise
  57. save: file_cache_save, // save(name, content) => Promise
  58. no_cache:file_nocache, // no_cache(modules)
  59. },
  60.  
  61. on: wait_event, // on(event, callback)
  62. trigger: trigger_event, // trigger(event[, data1[, data2[, ...]]])
  63.  
  64. get_state: get_state, // get(state_var)
  65. set_state: set_state, // set(state_var, value)
  66. wait_state: wait_state, // wait(state_var, value[, callback[, persistent]]) => if no callback, return one-shot Promise
  67.  
  68. version: {
  69. value: version,
  70. compare_to: compare_to, // compare_version(version)
  71. }
  72. };
  73.  
  74. published_interface.support_files = {
  75. 'jquery.js': 'https://ajax.googleapis.com/ajax/libs/jquery/3.6.1/jquery.min.js',
  76. 'jquery_ui.js': 'https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js',
  77. 'jqui_wkmain.css': 'https://raw.githubusercontent.com/rfindley/wanikani-open-framework/1550af8383ec28ad406cf401aee2de4c52446f6c/jqui-wkmain.css',
  78. };
  79.  
  80. //########################################################################
  81.  
  82. function split_list(str) {return str.replace(/、/g,',').replace(/[\s ]+/g,' ').trim().replace(/ *, */g, ',').split(',').filter(function(name) {return (name.length > 0);});}
  83. function promise(){let a,b,c=new Promise(function(d,e){a=d;b=e;});c.resolve=a;c.reject=b;return c;}
  84.  
  85. //########################################################################
  86.  
  87. //------------------------------
  88. // Compare the framework version against a specific version.
  89. //------------------------------
  90. function compare_to(client_version) {
  91. let client_ver = client_version.split('.').map(d => Number(d));
  92. let wkof_ver = version.split('.').map(d => Number(d));
  93. let len = Math.max(client_ver.length, wkof_ver.length);
  94. for (let idx = 0; idx < len; idx++) {
  95. let a = client_ver[idx] || 0;
  96. let b = wkof_ver[idx] || 0;
  97. if (a === b) continue;
  98. if (a < b) return 'newer';
  99. return 'older';
  100. }
  101. return 'same';
  102. }
  103.  
  104. //------------------------------
  105. // Include a list of modules.
  106. //------------------------------
  107. let include_promises = {};
  108.  
  109. function include(module_list) {
  110. if (wkof.get_state('wkof.wkof') !== 'ready') {
  111. return wkof.ready('wkof').then(function(){return wkof.include(module_list);});
  112. }
  113. let include_promise = promise();
  114. let module_names = split_list(module_list);
  115. let script_cnt = module_names.length;
  116. if (script_cnt === 0) {
  117. include_promise.resolve({loaded:[], failed:[]});
  118. return include_promise;
  119. }
  120.  
  121. let done_cnt = 0;
  122. let loaded = [], failed = [];
  123. let no_cache = split_list(localStorage.getItem('wkof.include.nocache') || '');
  124. for (let idx = 0; idx < module_names.length; idx++) {
  125. let module_name = module_names[idx];
  126. let module = supported_modules[module_name];
  127. if (!module) {
  128. failed.push({name:module_name, url:undefined});
  129. check_done();
  130. continue;
  131. }
  132. let await_load = include_promises[module_name];
  133. let use_cache = (no_cache.indexOf(module_name) < 0) && (no_cache.indexOf('*') < 0);
  134. if (!use_cache) file_cache_delete(module.url);
  135. if (await_load === undefined) include_promises[module_name] = await_load = load_script(module.url, use_cache);
  136. await_load.then(push_loaded, push_failed);
  137. }
  138.  
  139. return include_promise;
  140.  
  141. function push_loaded(url) {
  142. loaded.push(url);
  143. check_done();
  144. }
  145.  
  146. function push_failed(url) {
  147. failed.push(url);
  148. check_done();
  149. }
  150.  
  151. function check_done() {
  152. if (++done_cnt < script_cnt) return;
  153. if (failed.length === 0) include_promise.resolve({loaded:loaded, failed:failed});
  154. else include_promise.reject({error:'Failure loading module', loaded:loaded, failed:failed});
  155. }
  156. }
  157.  
  158. //------------------------------
  159. // Wait for all modules to report that they are ready
  160. //------------------------------
  161. function ready(module_list) {
  162. let module_names = split_list(module_list);
  163.  
  164. let ready_promises = [ ];
  165. for (let idx in module_names) {
  166. let module_name = module_names[idx];
  167. ready_promises.push(wait_state('wkof.' + module_name, 'ready'));
  168. }
  169.  
  170. if (ready_promises.length === 0) {
  171. return Promise.resolve();
  172. } else if (ready_promises.length === 1) {
  173. return ready_promises[0];
  174. } else {
  175. return Promise.all(ready_promises);
  176. }
  177. }
  178. //########################################################################
  179.  
  180. //------------------------------
  181. // Load a file asynchronously, and pass the file as resolved Promise data.
  182. //------------------------------
  183. function load_file(url, use_cache) {
  184. let fetch_promise = promise();
  185. let no_cache = split_list(localStorage.getItem('wkof.load_file.nocache') || '');
  186. if (no_cache.indexOf(url) >= 0 || no_cache.indexOf('*') >= 0) use_cache = false;
  187. if (use_cache === true) {
  188. return file_cache_load(url, use_cache).catch(fetch_url);
  189. } else {
  190. return fetch_url();
  191. }
  192.  
  193. // Retrieve file from server
  194. function fetch_url(){
  195. let request = new XMLHttpRequest();
  196. request.onreadystatechange = process_result;
  197. request.open('GET', url, true);
  198. request.send();
  199. return fetch_promise;
  200. }
  201.  
  202. function process_result(event){
  203. if (event.target.readyState !== 4) return;
  204. if (event.target.status >= 400 || event.target.status === 0) return fetch_promise.reject(event.target.status);
  205. if (use_cache) {
  206. file_cache_save(url, event.target.response)
  207. .then(fetch_promise.resolve.bind(null,event.target.response));
  208. } else {
  209. fetch_promise.resolve(event.target.response);
  210. }
  211. }
  212. }
  213.  
  214. //------------------------------
  215. // Load and install a specific file type into the DOM.
  216. //------------------------------
  217. function load_and_append(url, tag_name, location, use_cache) {
  218. url = url.replace(/"/g,'\'');
  219. if (document.querySelector(tag_name+'[uid="'+url+'"]') !== null) return Promise.resolve();
  220. return load_file(url, use_cache).then(append_to_tag);
  221.  
  222. function append_to_tag(content) {
  223. let tag = document.createElement(tag_name);
  224. tag.innerHTML = content;
  225. tag.setAttribute('uid', url);
  226. document.querySelector(location).appendChild(tag);
  227. return url;
  228. }
  229. }
  230.  
  231. //------------------------------
  232. // Load and install a CSS file.
  233. //------------------------------
  234. function load_css(url, use_cache) {
  235. return load_and_append(url, 'style', 'head', use_cache);
  236. }
  237.  
  238. //------------------------------
  239. // Load and install Javascript.
  240. //------------------------------
  241. function load_script(url, use_cache) {
  242. return load_and_append(url, 'script', 'head', use_cache);
  243. }
  244. //########################################################################
  245.  
  246. let state_listeners = {};
  247. let state_values = {};
  248.  
  249. //------------------------------
  250. // Get the value of a state variable, and notify listeners.
  251. //------------------------------
  252. function get_state(state_var) {
  253. return state_values[state_var];
  254. }
  255.  
  256. //------------------------------
  257. // Set the value of a state variable, and notify listeners.
  258. //------------------------------
  259. function set_state(state_var, value) {
  260. let old_value = state_values[state_var];
  261. if (old_value === value) return;
  262. state_values[state_var] = value;
  263.  
  264. // Do listener callbacks, and remove non-persistent listeners
  265. let listeners = state_listeners[state_var];
  266. let persistent_listeners = [ ];
  267. for (let idx in listeners) {
  268. let listener = listeners[idx];
  269. let keep = true;
  270. if (listener.value === value || listener.value === '*') {
  271. keep = listener.persistent;
  272. try {
  273. listener.callback(value, old_value);
  274. } catch (e) {}
  275. }
  276. if (keep) persistent_listeners.push(listener);
  277. }
  278. state_listeners[state_var] = persistent_listeners;
  279. }
  280.  
  281. //------------------------------
  282. // When state of state_var changes to value, call callback.
  283. // If persistent === true, continue listening for additional state changes
  284. // If value is '*', callback will be called for all state changes.
  285. //------------------------------
  286. function wait_state(state_var, value, callback, persistent) {
  287. let promise;
  288. if (callback === undefined) {
  289. promise = new Promise(function(resolve, reject) {
  290. callback = resolve;
  291. });
  292. }
  293. if (state_listeners[state_var] === undefined) state_listeners[state_var] = [ ];
  294. persistent = (persistent === true);
  295. let current_value = state_values[state_var];
  296. if (persistent || value !== current_value) state_listeners[state_var].push({callback:callback, persistent:persistent, value:value});
  297.  
  298. // If it's already at the desired state, call the callback immediately.
  299. if (value === current_value) {
  300. try {
  301. callback(value, current_value);
  302. } catch (err) {}
  303. }
  304. return promise;
  305. }
  306. //########################################################################
  307.  
  308. let event_listeners = {};
  309.  
  310. //------------------------------
  311. // Fire an event, which then calls callbacks for any listeners.
  312. //------------------------------
  313. function trigger_event(event) {
  314. let listeners = event_listeners[event];
  315. if (listeners === undefined) return;
  316. let args = [];
  317. Array.prototype.push.apply(args,arguments);
  318. args.shift();
  319. for (let idx in listeners) { try {
  320. listeners[idx].apply(null,args);
  321. } catch (err) {} }
  322. return global.wkof;
  323. }
  324.  
  325. //------------------------------
  326. // Add a listener for an event.
  327. //------------------------------
  328. function wait_event(event, callback) {
  329. if (event_listeners[event] === undefined) event_listeners[event] = [];
  330. event_listeners[event].push(callback);
  331. return global.wkof;
  332. }
  333.  
  334. //------------------------------
  335. // Add handlers for page events for a list of URLs.
  336. //------------------------------
  337. let page_handlers = [];
  338. let last_page_loaded = '!'
  339. function on_page_event(config) {
  340. if (!Array.isArray(config.urls)) config.urls = [config.urls];
  341. config.urls = config.urls.map((url) => {
  342. if (url instanceof RegExp) return url;
  343. if (typeof url !== 'string') return null;
  344. return new RegExp(url.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replaceAll('*','.*'));
  345. }).filter(url => url !== null);
  346. page_handlers.push(config);
  347. if (config.load) {
  348. config.urls.forEach(url => {
  349. if (!url.test(last_page_loaded)) return;
  350. config.load();
  351. });
  352. }
  353. }
  354.  
  355. //------------------------------
  356. // Call page event handlers.
  357. //------------------------------
  358. function handle_page_events(event_name, event) {
  359. if (event) {
  360. last_page_loaded = event.detail.url;
  361. } else {
  362. last_page_loaded = window.location.href
  363. }
  364. page_handlers.forEach(handler => {
  365. if (!handler.urls.find(url => url.test(last_page_loaded))) return;
  366. if (typeof handler[event_name] === 'function') handler[event_name](event_name);
  367. });
  368. }
  369.  
  370. //########################################################################
  371.  
  372. let file_cache_open_promise;
  373.  
  374. //------------------------------
  375. // Open the file_cache database (or return handle if open).
  376. //------------------------------
  377. function file_cache_open() {
  378. if (file_cache_open_promise) return file_cache_open_promise;
  379. let open_promise = promise();
  380. file_cache_open_promise = open_promise;
  381. let request;
  382. request = indexedDB.open('wkof.file_cache');
  383. request.onupgradeneeded = upgrade_db;
  384. request.onsuccess = get_dir;
  385. request.onerror = error;
  386. return open_promise;
  387.  
  388. function error() {
  389. console.log('indexedDB could not open!');
  390. wkof.file_cache.dir = {};
  391. if (ignore_missing_indexeddb) {
  392. open_promise.resolve(null);
  393. } else {
  394. open_promise.reject();
  395. }
  396. }
  397.  
  398. function upgrade_db(event){
  399. let db = event.target.result;
  400. let store = db.createObjectStore('files', {keyPath:'name'});
  401. }
  402.  
  403. function get_dir(event){
  404. let db = event.target.result;
  405. let transaction = db.transaction('files', 'readonly');
  406. let store = transaction.objectStore('files');
  407. let request = store.get('[dir]');
  408. request.onsuccess = process_dir;
  409. transaction.oncomplete = open_promise.resolve.bind(null, db);
  410. open_promise.then(setTimeout.bind(null, file_cache_cleanup, 10000));
  411. }
  412.  
  413. function process_dir(event){
  414. if (event.target.result === undefined) {
  415. wkof.file_cache.dir = {};
  416. } else {
  417. wkof.file_cache.dir = JSON.parse(event.target.result.content);
  418. }
  419. }
  420. }
  421.  
  422. //------------------------------
  423. // Lists the content of the file_cache.
  424. //------------------------------
  425. function file_cache_list() {
  426. console.log(Object.keys(wkof.file_cache.dir).sort().join('\n'));
  427. }
  428.  
  429. //------------------------------
  430. // Clear the file_cache database.
  431. //------------------------------
  432. function file_cache_clear() {
  433. return file_cache_open().then(clear);
  434.  
  435. function clear(db) {
  436. let clear_promise = promise();
  437. wkof.file_cache.dir = {};
  438. if (db === null) return clear_promise.resolve();
  439. let transaction = db.transaction('files', 'readwrite');
  440. let store = transaction.objectStore('files');
  441. store.clear();
  442. transaction.oncomplete = clear_promise.resolve;
  443. }
  444. }
  445.  
  446. //------------------------------
  447. // Delete a file from the file_cache database.
  448. //------------------------------
  449. function file_cache_delete(pattern) {
  450. return file_cache_open().then(del);
  451.  
  452. function del(db) {
  453. let del_promise = promise();
  454. if (db === null) return del_promise.resolve();
  455. let transaction = db.transaction('files', 'readwrite');
  456. let store = transaction.objectStore('files');
  457. let files = Object.keys(wkof.file_cache.dir).filter(function(file){
  458. if (pattern instanceof RegExp) {
  459. return file.match(pattern) !== null;
  460. } else {
  461. return (file === pattern);
  462. }
  463. });
  464. files.forEach(function(file){
  465. store.delete(file);
  466. delete wkof.file_cache.dir[file];
  467. });
  468. file_cache_dir_save();
  469. transaction.oncomplete = del_promise.resolve.bind(null, files);
  470. return del_promise;
  471. }
  472. }
  473.  
  474. //------------------------------
  475. // Force immediate save of file_cache directory.
  476. //------------------------------
  477. function file_cache_flush() {
  478. file_cache_dir_save(true /* immediately */);
  479. }
  480.  
  481. //------------------------------
  482. // Load a file from the file_cache database.
  483. //------------------------------
  484. function file_cache_load(name) {
  485. let load_promise = promise();
  486. return file_cache_open().then(load);
  487.  
  488. function load(db) {
  489. if (wkof.file_cache.dir[name] === undefined) {
  490. load_promise.reject(name);
  491. return load_promise;
  492. }
  493. let transaction = db.transaction('files', 'readonly');
  494. let store = transaction.objectStore('files');
  495. let request = store.get(name);
  496. wkof.file_cache.dir[name].last_loaded = new Date().toISOString();
  497. file_cache_dir_save();
  498. request.onsuccess = finish;
  499. request.onerror = error;
  500. return load_promise;
  501.  
  502. function finish(event){
  503. if (event.target.result === undefined || event.target.result === null) {
  504. load_promise.reject(name);
  505. } else {
  506. load_promise.resolve(event.target.result.content);
  507. }
  508. }
  509.  
  510. function error(event){
  511. load_promise.reject(name);
  512. }
  513. }
  514. }
  515.  
  516. //------------------------------
  517. // Save a file into the file_cache database.
  518. //------------------------------
  519. function file_cache_save(name, content, extra_attribs) {
  520. return file_cache_open().then(save);
  521.  
  522. function save(db) {
  523. let save_promise = promise();
  524. if (db === null) return save_promise.resolve(name);
  525. let transaction = db.transaction('files', 'readwrite');
  526. let store = transaction.objectStore('files');
  527. store.put({name:name,content:content});
  528. let now = new Date().toISOString();
  529. wkof.file_cache.dir[name] = Object.assign({added:now, last_loaded:now}, extra_attribs);
  530. file_cache_dir_save(true /* immediately */);
  531. transaction.oncomplete = save_promise.resolve.bind(null, name);
  532. }
  533. }
  534.  
  535. //------------------------------
  536. // Save a the file_cache directory contents.
  537. //------------------------------
  538. let fc_sync_timer;
  539. function file_cache_dir_save(immediately) {
  540. if (fc_sync_timer !== undefined) clearTimeout(fc_sync_timer);
  541. let delay = (immediately ? 0 : 2000);
  542. fc_sync_timer = setTimeout(save, delay);
  543.  
  544. function save(){
  545. file_cache_open().then(save2);
  546. }
  547.  
  548. function save2(db){
  549. fc_sync_timer = undefined;
  550. let transaction = db.transaction('files', 'readwrite');
  551. let store = transaction.objectStore('files');
  552. store.put({name:'[dir]',content:JSON.stringify(wkof.file_cache.dir)});
  553. }
  554. }
  555.  
  556. //------------------------------
  557. // Remove files that haven't been accessed in a while.
  558. //------------------------------
  559. function file_cache_cleanup() {
  560. let threshold = new Date() - 14*86400000; // 14 days
  561. let old_files = [];
  562. for (var fname in wkof.file_cache.dir) {
  563. if (fname.match(/^wkof\.settings\./)) continue; // Don't flush settings files.
  564. let fdate = new Date(wkof.file_cache.dir[fname].last_loaded);
  565. if (fdate < threshold) old_files.push(fname);
  566. }
  567. if (old_files.length === 0) return;
  568. console.log('Cleaning out '+old_files.length+' old file(s) from "wkof.file_cache":');
  569. for (let fnum in old_files) {
  570. console.log(' '+(Number(fnum)+1)+': '+old_files[fnum]);
  571. wkof.file_cache.delete(old_files[fnum]);
  572. }
  573. }
  574.  
  575. //------------------------------
  576. // Process no-cache requests.
  577. //------------------------------
  578. function file_nocache(list) {
  579. if (list === undefined) {
  580. list = split_list(localStorage.getItem('wkof.include.nocache') || '');
  581. list = list.concat(split_list(localStorage.getItem('wkof.load_file.nocache') || ''));
  582. console.log(list.join(','));
  583. } else if (typeof list === 'string') {
  584. let no_cache = split_list(list);
  585. let idx, modules = [], urls = [];
  586. for (idx = 0; idx < no_cache.length; idx++) {
  587. let item = no_cache[idx];
  588. if (supported_modules[item] !== undefined) {
  589. modules.push(item);
  590. } else {
  591. urls.push(item);
  592. }
  593. }
  594. console.log('Modules: '+modules.join(','));
  595. console.log(' URLs: '+urls.join(','));
  596. localStorage.setItem('wkof.include.nocache', modules.join(','));
  597. localStorage.setItem('wkof.load_file.nocache', urls.join(','));
  598. }
  599. }
  600.  
  601. function doc_ready() {
  602. wkof.set_state('wkof.document', 'ready');
  603. }
  604.  
  605. function is_turbo_page() {
  606. return (document.querySelector('script[type="importmap"]')?.innerHTML.match('@hotwired/turbo') != null);
  607. }
  608.  
  609. //########################################################################
  610. // Bootloader Startup
  611. //------------------------------
  612. function startup() {
  613. global.wkof = published_interface;
  614.  
  615. // Handle page-loading/unloading events.
  616. if (is_turbo_page()) {
  617. addEventListener('turbo:load', (e) => handle_page_events('load', e));
  618. } else {
  619. ready('document').then((e) => handle_page_events('load'));
  620. }
  621.  
  622. // Mark document state as 'ready'.
  623. if (document.readyState === 'complete') {
  624. doc_ready();
  625. } else {
  626. window.addEventListener("load", doc_ready, false); // Notify listeners that we are ready.
  627. }
  628.  
  629. // Open cache, so wkof.file_cache.dir is available to console immediately.
  630. file_cache_open();
  631. wkof.set_state('wkof.wkof', 'ready');
  632. }
  633. startup();
  634.  
  635. })(window);