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.

当前为 2023-11-10 提交的版本,查看 最新版本

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