Wanikani Level-Up Time Assistant

Shows the earliest date and time you can level up if your reviews are correct. Adds an indication if you have items available for lesson/review that are needed to advance your current level.

当前为 2024-08-25 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Wanikani Level-Up Time Assistant
  3. // @namespace https://greasyfork.org/en/users/11878
  4. // @version 1.3.7
  5. // @description Shows the earliest date and time you can level up if your reviews are correct. Adds an indication if you have items available for lesson/review that are needed to advance your current level.
  6. // @author Inserio
  7. // @match https://www.wanikani.com/*
  8. // @grant none
  9. // @license MIT
  10. // ==/UserScript==
  11. /* global wkof */
  12.  
  13. window.lu = {};
  14.  
  15. (function(lu_obj) {
  16. // ========================================================================
  17. // Initialization of the Wanikani Open Framework.
  18. // -------------------------------------------------------------------
  19.  
  20. const script_name = 'Wanikani Level-Up Time Assistant';
  21. const scriptId = 'Level-Up-Time-Assistant';
  22. const containerId = 'lu-container';
  23. const wkofTurboEventsScriptUrl = 'https://update.greasyfork.org/scripts/501980/1426667/Wanikani%20Open%20Framework%20Turbo%20Events.user.js';
  24. const wkof_version_needed = '1.0.53';
  25. if (!window.wkof) {
  26. if (confirm(script_name+' requires Wanikani Open Framework.\nDo you want to be forwarded to the installation instructions?'))
  27. window.location.href = 'https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549';
  28. return;
  29. }
  30. if (wkof.version.compare_to(wkof_version_needed) === 'older') {
  31. if (confirm(script_name+' requires Wanikani Open Framework version '+wkof_version_needed+'.\nDo you want to be forwarded to the update page?'))
  32. window.location.href = 'https://greasyfork.org/en/scripts/38582-wanikani-open-framework';
  33. return;
  34. }
  35.  
  36. // ========================================================================
  37. // Globals
  38. // -------------------------------------------------------------------
  39.  
  40. // TODO: Perhaps make the logging option configurable in a settings menu?
  41. var config = {
  42. log: {
  43. enabled: false,
  44. detailed: true
  45. },
  46. callback: null
  47. };
  48. var items_by_subject_id;
  49. var items_by_type;
  50.  
  51. // ========================================================================
  52. // Startup
  53. // -------------------------------------------------------------------
  54. lu_obj.items_with_soonest_assignments = [];
  55. lu_obj.items_not_passed_with_assignments_available = [];
  56. install_css();
  57.  
  58. wkof.load_script(wkofTurboEventsScriptUrl, /* use_cache */ true);
  59. wkof.include('ItemData');
  60. wkof.ready('TurboEvents').then(configureEventHandler);
  61.  
  62. function configureEventHandler() {
  63. wkof.turbo.on.common.dashboard(startup);
  64. }
  65.  
  66. function startup() {
  67. init_ui();
  68. wkof.ready('ItemData').then(fetch_items);
  69. }
  70.  
  71. /**
  72. * Install stylesheet.
  73. */
  74. function install_css() {
  75. const lu_css = `<style id="${scriptId}">`+
  76. '#lu-container{display:flex;align-items:center;justify-content:space-evenly;margin:0 5px 12px 5px;}'+
  77. '#lu-container #lu-arrow-up{height:20px;width:20px;padding:3px;font-size:14px;font-family:"Noto Sans JP", "Noto Sans SC", sans-serif;color:white;background-color:darkgray;cursor:default;border-radius:14px;}'+
  78. '#lu-container #lu-arrow-up.levelup-items{background-color:#00ff00;cursor:pointer;animation:lu-pulse 1s infinite;}'+
  79. '.hidden {visibility:hidden;}'+
  80. '@keyframes lu-pulse{'+
  81. '0%,100%{-ms-transform:scale(1);-o-transform:scale(1);-webkit-transform:scale(1);-moz-transform:scale(1);transform:scale(1);}'+
  82. '50%{-ms-transform:scale(1.25);-o-transform:scale(1.25);-webkit-transform:scale(1.25);-moz-transform:scale(1.25);transform:scale(1.25);}'+
  83. '}'+'</style>';
  84. document.getElementsByTagName('head')[0].insertAdjacentHTML('beforeend',lu_css);
  85. }
  86.  
  87. /**
  88. * Initialize the user interface.
  89. */
  90. function init_ui() {
  91. if (document.getElementById(containerId) !== null) return;
  92. const lu_html = `<div id="${containerId}" class="hidden"><span id="lu-arrow-up" title="${get_text_for_icon_tooltip()}">&#x2B06;</span><strong>Earliest Level Up: </strong><span id="lu-level-up-date"></span></div>`;
  93. document.querySelector('.dashboard__review-forecast > .wk-panel--review-forecast > :first-child').insertAdjacentHTML('beforebegin',lu_html);
  94. }
  95.  
  96. // ========================================================================
  97. // Populate level info from API.
  98. // -------------------------------------------------------------------
  99. function fetch_items() {
  100. // Fetch only radicals and kanji for current level.
  101. // Include /subjects and /assignments endpoints
  102. wkof.ItemData.get_items({
  103. wk_items:{
  104. options:{
  105. assignments:true
  106. },
  107. filters:{
  108. level:'+0',
  109. item_type:'rad,kan'
  110. }
  111. }
  112. }).then(prepare_items);
  113. }
  114.  
  115. function prepare_items(items) {
  116. lu_obj.load_time = new Date();
  117.  
  118. items_by_type = wkof.ItemData.get_index(items,'item_type');
  119. items_by_subject_id = wkof.ItemData.get_index(items,'subject_id');
  120.  
  121. // Add "earliest_study_date" and "earliest_guru_date" properties to items
  122. // Need to parse radicals first so that locked kanji get the proper dates assigned to them
  123. add_dates_to_items((items_by_type.radical ? items_by_type.radical : []).concat(items_by_type.kanji));
  124.  
  125. // Sort the items by the earliest_study_date, then the earliest_guru_date, then their subject_id. This will determine how they appear in the console.
  126. lu_obj.items = items.sort(get_sort_method('+earliest_study_date','+earliest_guru_date','+data.subject_id'));
  127.  
  128. // Cache these filters for quick lookups
  129. lu_obj.items_not_locked_and_not_passed = get_not_locked_but_not_passed_items(lu_obj.items);
  130. lu_obj.items_not_passed_with_assignments_available = get_not_passed_items_with_available_assignments(lu_obj.items);
  131. lu_obj.items_with_soonest_assignments = get_next_soonest_study_items(lu_obj.items_not_locked_and_not_passed);
  132.  
  133. // Log the results to the console
  134. log_base_items_stats();
  135.  
  136. // Get the level up date and update the UI
  137. process_items();
  138.  
  139. // Setup a callback to fetch new data and re-run the UI updates when current level radicals/kanji become available
  140. setup_next_reviews_callback();
  141. }
  142.  
  143. function process_items() {
  144. lu_obj.level_up_date = get_level_up_date();
  145. let lu_container = document.getElementById('lu-container');
  146. if (!lu_container)
  147. return;
  148. if (lu_container.classList.contains('hidden'))
  149. lu_container.classList.remove('hidden');
  150. update_ui();
  151. let lu_level_up_date = document.getElementById('lu-level-up-date');
  152. let lu_arrow_up = document.getElementById('lu-arrow-up');
  153. if (lu_arrow_up) {
  154. lu_arrow_up.onmouseover = function(){update_ui('lu-arrow-up');};
  155. }
  156. if (lu_level_up_date) {
  157. lu_level_up_date.onmouseover = function(){update_ui('lu-level-up-date');};
  158. lu_level_up_date.onclick = function(){config.log.enabled = true; fetch_items();};
  159. }
  160. }
  161.  
  162. function update_date_title() {
  163. let lu_level_up_date = document.getElementById('lu-level-up-date');
  164. if (!lu_level_up_date) return;
  165. let dateOutput = format_date_to_standard_output(lu_obj.level_up_date, false);
  166. let wait_time = format_two_dates_diff_to_minimal_output(lu_obj.level_up_date,lu_obj.load_time,true);
  167. lu_level_up_date.innerHTML = dateOutput;
  168. lu_level_up_date.title = (wait_time==='Now' ? 'Available now' : wait_time)+'\nClick to update data and log results to the console';
  169. }
  170.  
  171. function update_arrow_title() {
  172. let lu_arrow_up = document.getElementById('lu-arrow-up');
  173. if (!lu_arrow_up) return;
  174. let title = '';
  175. let next_items = lu_obj.items_with_soonest_assignments;
  176. let item_count = next_items.length;
  177. if (item_count>0) {
  178. let review_time = format_date_to_standard_output(next_items[0].earliest_study_date,true,true);
  179. if (review_time === 'Now') {
  180. title = `${item_count} item${item_count===1 ? ' needed to pass this level is' : 's needed to pass this level are'}`+
  181. ' currently available to study\nClick here to proceed to a new session';
  182. next_items = unique_from(get_next_soonest_study_items(lu_obj.items_not_locked_and_not_passed,next_items));
  183. item_count = next_items.length;
  184. if (item_count>0) {
  185. title += '\n\n';
  186. review_time = format_date_to_standard_output(next_items[0].earliest_study_date,true,true);
  187. }
  188. }
  189. if (item_count>0) {
  190. title += `The next ${item_count} item${item_count>1 ? 's' : ''} of the ones needed to pass this level will arrive at:\n${(review_time)}`;
  191. }
  192. } else {
  193. title = get_text_for_icon_tooltip();
  194. }
  195. lu_arrow_up.title = title;
  196. if (lu_obj.items_not_passed_with_assignments_available.length>0) {
  197. let destination = lu_obj.items_not_passed_with_assignments_available.find(a=>!a.assignments.available_at) ? 'lesson' : 'review';
  198. lu_arrow_up.onclick = function(){window.location='https://www.wanikani.com/subjects/'+destination;};
  199. lu_arrow_up.classList.add('levelup-items');
  200. } else {
  201. lu_arrow_up.onclick = null;
  202. lu_arrow_up.classList.remove('levelup-items');
  203. }
  204. }
  205.  
  206. function update_ui(element_id) {
  207. lu_obj.load_time = new Date();
  208. if (!element_id || element_id === 'lu-level-up-date')
  209. update_date_title();
  210. if (!element_id || element_id === 'lu-arrow-up')
  211. update_arrow_title();
  212. }
  213.  
  214. function setup_next_reviews_callback() {
  215. if (config.callback) {
  216. clearTimeout(config.callback);
  217. config.callback = null;
  218. }
  219. if (lu_obj.items_with_soonest_assignments.length<=0) return;
  220. let time_diff = lu_obj.items_with_soonest_assignments[0].earliest_study_date.getTime() - (new Date()).getTime();
  221. if (time_diff <= 0) return;
  222. config.callback = setTimeout(function() {
  223. config.callback = null;
  224. let log_enabled = config.log.enabled;
  225. config.log.enabled = false;
  226. fetch_items();
  227. config.log.enabled = log_enabled;
  228. }, time_diff);
  229. }
  230.  
  231. function log_base_items_stats() {
  232. if (!config.log.enabled) return;
  233. const max_date_len = 48;
  234. let items_not_passed_by_type = wkof.ItemData.get_index(get_not_passed_items(lu_obj.items), 'item_type');
  235. let items_locked_by_type = wkof.ItemData.get_index(get_locked_items(lu_obj.items), 'item_type');
  236. for (let itype of Object.keys(items_not_passed_by_type).sort((a,b)=>a.localeCompare(b)*-1)) {
  237. let not_passed_items = items_not_passed_by_type[itype];
  238. if (!not_passed_items || not_passed_items.length<=0) continue;
  239. let str = [`${not_passed_items.length} remaining ${itype}${itype==='radical'&&not_passed_items.length>1 ? 's' : ''} to guru`+
  240. (items_locked_by_type[itype]&&items_locked_by_type[itype].length>0 ? ' ('+items_locked_by_type[itype].length+' of which are still locked)' : '')];
  241. if (config.log.detailed) {
  242. for (let i=0; i<not_passed_items.length; i++) {
  243. let itm = not_passed_items[i];
  244. let next_study_time = format_date_to_standard_output(itm.earliest_study_date,true);
  245. let earliest_guru_time = format_date_to_standard_output(itm.earliest_guru_date,true);
  246. str.push((!itm.assignments||!itm.assignments.unlocked_at ? '🔒 ' : '')+(itm.data.characters ? itm.data.characters : itm.data.slug)+'\t'+
  247. '| Stage: '+(itm.assignments ? itm.assignments.srs_stage : 0)+'/5\t'+
  248. '| Next Study: '+next_study_time+'\t'.repeat(Math.ceil((max_date_len-next_study_time.length)/8))+
  249. '| Earliest Guru: '+earliest_guru_time+'\t'.repeat(Math.ceil((max_date_len-earliest_guru_time.length)/8))+
  250. '| '+itm.data.document_url);
  251. }
  252. }
  253. console.log(str.join('\n'));
  254. }
  255. }
  256.  
  257. // ========================================================================
  258. // Formatting
  259. // -------------------------------------------------------------------
  260.  
  261. function get_text_for_icon_tooltip() {
  262. let items = lu_obj.items_not_passed_with_assignments_available;
  263. return `${items.length} item${items.length===1 ? '' : 's'} needed to level ${(items.length===1 ? 'is' : 'are')} currently available to study`;
  264. }
  265.  
  266. function format_two_dates_diff_to_minimal_output(date, date2, include_seconds) {
  267. let diff = Math.max(0, Math.trunc(date.getTime()/1000)-Math.trunc(date2.getTime()/1000));
  268. let dd = Math.floor(diff / 86400);
  269. diff -= dd*86400;
  270. let hh = Math.floor(diff / 3600);
  271. diff -= hh*3600;
  272. let mm = Math.floor(diff / 60);
  273. diff -= mm*60;
  274. let ss = diff;
  275. if (dd > 0) {
  276. return dd+' day'+(dd===1?'':'s')+', '+hh+' hour'+(hh===1?'':'s')+', '+mm+' min'+(mm===1?'':'s')+(include_seconds?', '+ss+' sec'+(ss===1?'':'s'):'');
  277. } else if (hh > 0) {
  278. return hh+' hour'+(hh===1?'':'s')+', '+mm+' min'+(mm===1?'':'s')+(include_seconds?', '+ss+' sec'+(ss===1?'':'s'):'');
  279. } else if (mm > 0 || ss > 0) {
  280. if (!include_seconds && ss > 30) mm++;
  281. return mm+' min'+(mm===1?'':'s')+(include_seconds?', '+ss+' sec'+(ss===1?'':'s'):'');
  282. } else {
  283. return 'Now';
  284. }
  285. }
  286.  
  287. function format_date_to_standard_output(date, include_differential, include_seconds) {
  288. if (!(date instanceof Date)) date = new Date(date);
  289. if (date.getTime() === (new Date(0)).getTime()) return 'N/A';
  290. if (date.getTime() <= lu_obj.load_time.getTime()) return "Now";
  291. let str = date.toLocaleString([],{weekday:"short",month:"short",day:"numeric",hour12:false,hour:"numeric",minute:"numeric"});
  292. if (!include_differential) return str;
  293. return str +' ('+format_two_dates_diff_to_minimal_output(date,lu_obj.load_time, include_seconds)+')';
  294. }
  295.  
  296. // ========================================================================
  297. // Transformers and Helpers
  298. // -------------------------------------------------------------------
  299.  
  300. /**
  301. * Returns a non-destructive Array of elements that are not found in
  302. * any of the parameter arrays.
  303. * Assumes all items have a property named "id"
  304. *
  305. * @param {...Array} var_args Arrays to compare.
  306. */
  307. function unique_from(arr1, ...args) {
  308. if (!args.length) return [];
  309. let out = [];
  310. let map = new Map();
  311. for (let n=0; n < args.length; n++) {
  312. let a2 = args[n];
  313. if (!(a2 instanceof Array))
  314. throw new TypeError( 'argument ['+n+'] must be an Array' );
  315. // Add existing id from the array to the map
  316. for (let i=0; i<a2.length; i++)
  317. map.set(a2[i].id, true);
  318. }
  319. // Add to the new array all items that aren't included in the map (map lookUp is O(1) complexity)
  320. for(let i=0; i<arr1.length; i++)
  321. if (!map.get(arr1[i].id))
  322. out.push(arr1[i]);
  323. return out;
  324. }
  325.  
  326. /**
  327. * Get an Array sort function with multiple subarray fields.
  328. * Prefix letter allows specifying whether to sort ascending "+" or descending "-" (default: ascending)
  329. * Splits and recurses properties properly on periods
  330. * e.g.
  331. * let arr = [{obj:{prop1:'1'}, prop2: 3},{obj:{prop1:'3'}, prop2: 4},{obj:{prop1:'3'}, prop2: 2},{obj:{prop1:'4'}, prop2: 2}];
  332. * arr.sort(get_sort_method('+prop2','-obj.prop1'));
  333. * // [{obj:{prop1:'4'}, prop2: 2},{obj:{prop1:'3'}, prop2: 2},{obj:{prop1:'1'}, prop2: 3},{obj:{prop1:'3'}, prop2: 4}]
  334. */
  335. function get_sort_method(){
  336. let argsArr = Array.prototype.slice.call(arguments);
  337. return function(a, b){
  338. for(let x in argsArr){
  339. let strStart = 1;
  340. let op = argsArr[x].substring(0,1);
  341. if (op !== "-" && op !== "+") {op = "+";strStart = 0;}
  342. let prop = argsArr[x].substring(strStart);
  343. prop = prop.split('.');
  344. let len = prop.length;
  345. let i = 0;
  346. let ax = a;
  347. let bx = b;
  348. let cx;
  349. while(i<len) {ax = ax[prop[i]]; bx = bx[prop[i]]; i++;}
  350. ax = typeof ax == "string" ? ax.toLowerCase() : ax / 1;
  351. bx = typeof bx == "string" ? bx.toLowerCase() : bx / 1;
  352. if(op === "-"){cx = ax; ax = bx; bx = cx;}
  353. if(ax !== bx){return ax < bx ? -1 : 1;}
  354. }
  355. };
  356. }
  357.  
  358. function add_dates_to_items(items) {
  359. for (let i=0; i<items.length; i++) {
  360. let itm = items[i];
  361. if (!itm) continue;
  362. itm.earliest_study_date = (itm.assignments && itm.assignments.unlocked_at ?
  363. new Date((itm.assignments.started_at ?
  364. itm.assignments.available_at :
  365. itm.assignments.unlocked_at)) :
  366. get_earliest_unlock_date(itm));
  367. itm.earliest_guru_date = get_item_guru_date(itm);
  368. }
  369. }
  370.  
  371. function get_not_passed_items(items) {
  372. let output_items = [];
  373. for (let i=0; i<items.length; i++) {
  374. let itm = items[i];
  375. if (itm && (!itm.assignments || !itm.assignments.passed_at))
  376. output_items.push(itm);
  377. }
  378. return output_items;
  379. }
  380.  
  381. function get_not_locked_but_not_passed_items(items) {
  382. let output_items = [];
  383. for (let i=0; i<items.length; i++) {
  384. let itm = items[i];
  385. if (itm && itm.assignments && itm.assignments.unlocked_at && !itm.assignments.passed_at)
  386. output_items.push(itm);
  387. }
  388. return output_items;
  389. }
  390.  
  391. function get_not_passed_items_with_available_assignments(items) {
  392. let output_items = [];
  393. for (let i=0; i<items.length; i++) {
  394. let itm = items[i];
  395. if (itm && itm.assignments && itm.assignments.unlocked_at && !itm.assignments.passed_at && itm.earliest_study_date.getTime()<=lu_obj.load_time.getTime())
  396. output_items.push(itm);
  397. }
  398. return output_items;
  399. }
  400.  
  401. function get_locked_items(items) {
  402. let output_items = [];
  403. for (let i=0; i<items.length; i++) {
  404. let itm = items[i];
  405. if (itm && (!itm.assignments || !itm.assignments.unlocked_at))
  406. output_items.push(itm);
  407. }
  408. return output_items;
  409. }
  410.  
  411. function get_next_soonest_study_items(items) {
  412. return items.reduce((acc,itm)=>{
  413. let min_date = acc.length>0 ? acc[0].earliest_study_date : itm.earliest_study_date;
  414. if (itm.earliest_study_date.getTime() > lu_obj.load_time.getTime() && itm.earliest_study_date.getTime() < min_date.getTime()) {
  415. min_date = itm.earliest_study_date;
  416. acc.length = 0;
  417. }
  418. if (itm.earliest_study_date.getTime() <= lu_obj.load_time.getTime() || itm.earliest_study_date.getTime() === min_date.getTime())
  419. acc.push(itm);
  420. itm.earliest_guru_date = get_item_guru_date(itm);
  421. return acc;
  422. },[]);
  423. }
  424.  
  425. /**
  426. * Gets the earliest date that the provided item can be unlocked if all components are gurued as soon as possible
  427. */
  428. function get_earliest_unlock_date(item) {
  429. let min_date;
  430. for (let rad_idx = 0; rad_idx < item.data.component_subject_ids.length; rad_idx++) {
  431. let rad = items_by_subject_id[item.data.component_subject_ids[rad_idx]];
  432. if (rad && (!min_date || rad.earliest_guru_date.getTime() > min_date.getTime()))
  433. min_date = new Date(rad.earliest_guru_date);
  434. }
  435. return min_date;
  436. }
  437.  
  438. /**
  439. * Calculate item guru date
  440. */
  441. function get_item_guru_date(item){
  442. let hours_to_guru = 0;
  443. if (!item || !item.earliest_study_date && (!item.assignments || !item.assignments.unlocked_at))
  444. return new Date(0); // This is mostly for debugging. If you see the 12/31/1969 date anywhere, this is where it went wrong.
  445. if (item.assignments && item.assignments.passed_at)
  446. return new Date(item.assignments.passed_at);
  447. switch (item.assignments ? item.assignments.srs_stage : 0) {
  448. case 0: hours_to_guru += 4+8+23+47; break;
  449. case 1: hours_to_guru += 8+23+47; break;
  450. case 2: hours_to_guru += 23+47; break;
  451. case 3: hours_to_guru += 47; break;
  452. }
  453. // Add the hours to the available date, or the unlock date if the item is locked, or current date something went wrong
  454. let earliest_guru_date = new Date(item.earliest_study_date);
  455. if (earliest_guru_date.getTime() < lu_obj.load_time.getTime())
  456. earliest_guru_date = new Date(lu_obj.load_time);
  457. earliest_guru_date.setHours(earliest_guru_date.getHours()+hours_to_guru);
  458. return earliest_guru_date;
  459. }
  460.  
  461. /**
  462. * Get earliest possible level up date
  463. * Calculated by sorting the kanji by earliest possible guru date and taking the time from the 90% to last kanji
  464. */
  465. function get_level_up_date() {
  466. if (!items_by_type.kanji) return new Date(0);
  467. let kanji_items = items_by_type.kanji.sort((a,b)=>a.earliest_guru_date.getTime()-b.earliest_guru_date.getTime());
  468. return new Date(kanji_items[Math.ceil(kanji_items.length * 0.9)-1].earliest_guru_date);
  469. }
  470.  
  471. })(window.lu);