Wanikani Level Up Time (Evolved)

Calculates what day and time you can level up if your reviews are correct. Based off the version by Reeko182.

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

  1. // ==UserScript==
  2. // @name Wanikani Level Up Time (Evolved)
  3. // @namespace https://greasyfork.org/en/users/11878
  4. // @version 1.2.0
  5. // @description Calculates what day and time you can level up if your reviews are correct. Based off the version by Reeko182.
  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.27';
  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. var config = {
  39. log: {
  40. enabled: true,
  41. detailed: true
  42. },
  43. callback: null
  44. }
  45. var items_by_subject_id;
  46. var items_by_type;
  47.  
  48. //========================================================================
  49. // Startup
  50. //-------------------------------------------------------------------
  51.  
  52. wkof.include('ItemData');
  53. startup();
  54.  
  55. wkof.ready('ItemData').then(fetch_items);
  56.  
  57. function startup() {
  58. lu_obj.items_with_soonest_assignments = [];
  59. lu_obj.items_not_passed_with_assignments_available = [];
  60. install_css();
  61. init_ui();
  62. }
  63.  
  64. /**
  65. * Install stylesheet.
  66. */
  67. function install_css() {
  68. const lu_css =
  69. '#lu_container { margin: auto auto 12px auto; color: #555; font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; font-weight: 300; text-shadow: 0 1px 0 #fff; font-size:13px; }'+
  70. '#lu_container i { padding-left:5px; padding-right:5px; font-size:14px; }';
  71. $('head').append('<style>'+lu_css+'</style>');
  72. }
  73.  
  74. /**
  75. * Initialize the user interface.
  76. */
  77. function init_ui() {
  78. const html =
  79. "<div id='lu_container'>"+
  80. "<i id='lu_arrow_up' class='fa-solid fa-circle-arrow-up' title='"+get_text_for_icon_tooltip()+"'></i>"+
  81. "<strong>Earliest Level Up: </strong><span id='lu_level_up_date'></span>"+
  82. "</div>";
  83. $('.progress-and-forecast > .wk-panel--review-forecast > :first-child').before(html);
  84. }
  85.  
  86. //========================================================================
  87. // Populate level info from API.
  88. //-------------------------------------------------------------------
  89. function fetch_items() {
  90. // Fetch only radicals and kanji for current level.
  91. // Include /subjects and /assignments endpoints
  92. wkof.ItemData.get_items({
  93. wk_items:{
  94. options:{
  95. assignments:true,
  96. review_statistics: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'].concat(items_by_type['kanji']));
  115.  
  116. lu_obj.items = items.sort(get_sort_method('+earliest_study_date','+earliest_guru_date','+data.subject_id'));
  117.  
  118. //lu_obj.items_accessible = get_accessible_items(lu_obj.items);
  119. //lu_obj.items_not_passed = get_not_passed_items(lu_obj.items);
  120. //lu_obj.items_inaccessible = get_inaccessible_items(lu_obj.items);
  121. //lu_obj.items_unlocked = get_unlocked_items(lu_obj.items);
  122. //lu_obj.items_locked = get_locked_items(lu_obj.items);
  123. // Finished non-dependant items
  124.  
  125. lu_obj.items_not_locked_and_not_passed = get_not_locked_but_not_passed_items(lu_obj.items);
  126. lu_obj.items_not_passed_with_assignments_available = get_not_passed_items_with_available_assignments(lu_obj.items);
  127. lu_obj.items_with_soonest_assignments = get_next_soonest_study_items(lu_obj.items_not_locked_and_not_passed);
  128.  
  129. log_base_items_stats();
  130. config.log.enabled = false;
  131. process_items();
  132. setup_next_reviews_callback();
  133. }
  134.  
  135. // TODO: port this over to newer logic
  136. function process_items() {
  137. lu_obj.level_up_date = get_level_up_date();
  138. update_ui();
  139. let lu_level_up_date = document.getElementById('lu_level_up_date');
  140. let lu_arrow_up = document.getElementById('lu_arrow_up');
  141. if (lu_arrow_up) {
  142. lu_arrow_up.onmouseover = function(){update_ui('lu_arrow_up');};
  143. }
  144. if (lu_level_up_date) {
  145. lu_level_up_date.onmouseover = function(){update_ui('lu_level_up_date');};
  146. lu_level_up_date.onclick = function(){config.log.enabled = true; fetch_items();}
  147. }
  148. }
  149.  
  150. function update_date_title() {
  151. let lu_level_up_date = document.getElementById('lu_level_up_date');
  152. if (!lu_level_up_date) return;
  153. let dateOutput = format_date_to_standard_output(lu_obj.level_up_date, false);
  154. let wait_time = format_two_dates_diff_to_minimal_output(lu_obj.level_up_date,lu_obj.load_time, true);
  155. lu_level_up_date.innerHTML = dateOutput;
  156. lu_level_up_date.title = (wait_time==='Now'?'Available now':wait_time)+'\nClick to update data (and repeat logging to console)';
  157. }
  158.  
  159. function update_arrow_title() {
  160. let lu_arrow_up = document.getElementById('lu_arrow_up');
  161. if (!lu_arrow_up) return;
  162. let title = '';
  163. if (lu_obj.items_with_soonest_assignments.length>0) {
  164. let review_time = format_date_to_standard_output(lu_obj.items_with_soonest_assignments[0].earliest_study_date,true, true);
  165. let next_items = lu_obj.items_with_soonest_assignments;
  166. if (review_time === 'Now') {
  167. title = `${lu_obj.items_with_soonest_assignments.length} item${lu_obj.items_with_soonest_assignments.length===1?' needed to pass this level is':'s needed to pass this level are'} currently available to study\nClick here to proceed to a new session`;
  168. next_items = unique_from(get_next_soonest_study_items(lu_obj.items_not_locked_and_not_passed,lu_obj.items_with_soonest_assignments));
  169. if (next_items.length>0) {
  170. title += '\n\n'
  171. review_time = format_date_to_standard_output(next_items[0].earliest_study_date,true, true);
  172. }
  173. }
  174. if (next_items.length>0) {
  175. 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)}`;
  176. }
  177. } else {
  178. title = get_text_for_icon_tooltip();
  179. }
  180. lu_arrow_up.title = title;
  181. if (lu_obj.items_not_passed_with_assignments_available.length>0) {
  182. let destination = lu_obj.items_not_passed_with_assignments_available.find(a=>!a.assignments.available_at) ? 'lesson' : 'review';
  183. lu_arrow_up.onclick = function(){window.location='https://www.wanikani.com/subjects/'+destination};
  184. lu_arrow_up.style.setProperty('color', '#00ff00');
  185. lu_arrow_up.classList.add("fa-beat")
  186. } else {
  187. lu_arrow_up.onclick = null;
  188. lu_arrow_up.style.removeProperty('color');
  189. lu_arrow_up.classList.remove("fa-beat")
  190. }
  191. }
  192.  
  193. function update_ui(element_id) {
  194. lu_obj.load_time = new Date();
  195. if (!element_id || element_id === 'lu_level_up_date')
  196. update_date_title()
  197. if (!element_id || element_id === 'lu_arrow_up')
  198. update_arrow_title()
  199. }
  200.  
  201. function setup_next_reviews_callback() {
  202. if (config.callback) {
  203. clearTimeout(config.callback);
  204. config.callback = null;
  205. }
  206. if (lu_obj.items_with_soonest_assignments.length<=0) return;
  207. let time_diff = lu_obj.items_with_soonest_assignments[0].earliest_study_date.getTime() - (new Date()).getTime();
  208. if (time_diff <= 0) return;
  209. setTimeout(function() {
  210. config.callback = null;
  211. //lu_obj.load_time = new Date();
  212. fetch_items();
  213. }, time_diff);
  214. }
  215.  
  216. function log_base_items_stats() {
  217. if (!config.log.enabled) return;
  218. const max_date_len = 48;
  219. let items_not_passed_by_type = wkof.ItemData.get_index(get_not_passed_items(lu_obj.items), 'item_type');//lu_obj.items_not_passed, 'item_type');
  220. let items_locked_by_type = wkof.ItemData.get_index(get_locked_items(lu_obj.items), 'item_type');//lu_obj.items_locked, 'item_type');
  221. for (let itype of Object.keys(items_not_passed_by_type).sort((a,b)=>a.localeCompare(b)*-1)) {
  222. let not_passed_items = items_not_passed_by_type[itype];
  223. if (!not_passed_items || not_passed_items.length<=0) continue;
  224. let str = [`${not_passed_items.length} remaining ${itype}${itype==='radical'&&not_passed_items.length>1?'s':''} to guru`
  225. +(items_locked_by_type[itype]&&items_locked_by_type[itype].length>0?' ('+items_locked_by_type[itype].length+' of which are still locked)':'')];
  226. if (config.log.detailed) {
  227. for (let i=0; i<not_passed_items.length; i++) {
  228. let itm = not_passed_items[i];
  229. let next_study_time = format_date_to_standard_output(itm.earliest_study_date,true)
  230. let earliest_guru_time = format_date_to_standard_output(itm.earliest_guru_date,true)
  231. str.push((!itm.assignments||!itm.assignments.unlocked_at?'🔒 ':'')+(itm.data.characters?itm.data.characters:itm.data.slug)+'\t'
  232. +'| Stage: '+(itm.assignments?itm.assignments.srs_stage:0)+'/5\t'
  233. +'| Next Study: '+next_study_time+'\t'.repeat(Math.ceil((max_date_len-next_study_time.length)/8))
  234. +'| Earliest Guru: '+earliest_guru_time+'\t'.repeat(Math.ceil((max_date_len-earliest_guru_time.length)/8))
  235. +'| '+itm.data.document_url);
  236. }
  237. }
  238. console.log(str.join('\n'));
  239. }
  240. }
  241.  
  242. //========================================================================
  243. // Formatting
  244. //-------------------------------------------------------------------
  245.  
  246. function get_text_for_icon_tooltip() {
  247. return `${lu_obj.items_not_passed_with_assignments_available.length} item${lu_obj.items_not_passed_with_assignments_available.length===1?'':'s'} needed to level ${(lu_obj.items_not_passed_with_assignments_available.length===1?'is':'are')} currently available to study`;
  248. }
  249.  
  250. function format_two_dates_diff_to_minimal_output(date, date2, include_seconds) {
  251. let diff = Math.max(0, Math.trunc(date.getTime()/1000)-Math.trunc(date2.getTime()/1000));
  252. let dd = Math.floor(diff / 86400);
  253. diff -= dd*86400;
  254. let hh = Math.floor(diff / 3600);
  255. diff -= hh*3600;
  256. let mm = Math.floor(diff / 60);
  257. diff -= mm*60;
  258. let ss = diff;
  259. if (dd > 0) {
  260. return dd+' day'+(dd===1?'':'s')+', '+hh+' hour'+(hh===1?'':'s')+', '+mm+' min'+(mm===1?'':'s')+(include_seconds?', '+ss+' sec'+(ss===1?'':'s'):'');
  261. } else if (hh > 0) {
  262. return hh+' hour'+(hh===1?'':'s')+', '+mm+' min'+(mm===1?'':'s')+(include_seconds?', '+ss+' sec'+(ss===1?'':'s'):'');
  263. } else if (mm > 0 || ss > 0) {
  264. if (!include_seconds && ss > 30) mm++;
  265. return mm+' min'+(mm===1?'':'s')+(include_seconds?', '+ss+' sec'+(ss===1?'':'s'):'');
  266. } else {
  267. return 'Now';
  268. }
  269. }
  270.  
  271. function format_date_to_standard_output(date, include_differential, include_seconds) {
  272. if (!(date instanceof Date)) date = new Date(date);
  273. if (date.getTime() <= lu_obj.load_time.getTime()) return "Now";
  274. let str = date.toLocaleString([],{weekday:"short",month:"short",day:"numeric",hour12:false,hour:"numeric",minute:"numeric"});
  275. if (!include_differential) return str;
  276. return str +' ('+format_two_dates_diff_to_minimal_output(date,lu_obj.load_time, include_seconds)+')';
  277. }
  278.  
  279. //========================================================================
  280. // Transformers and Helpers
  281. //-------------------------------------------------------------------
  282.  
  283. /**
  284. * Returns a non-destructive Array of elements that are not found in
  285. * any of the parameter arrays.
  286. * Assumes all items have a property named "id"
  287. *
  288. * @param {...Array} var_args Arrays to compare.
  289. */
  290. //Object.defineProperty(Array.prototype, 'unique_from', {value(){
  291. function unique_from(arr1, ...args) {
  292. if (!args.length) return [];
  293. let out = [];
  294. let map = new Map();
  295. for (let n=0; n < args.length; n++) {
  296. let a2 = args[n];
  297. if (!(a2 instanceof Array))
  298. throw new TypeError( 'argument ['+n+'] must be an Array' );
  299. //add existing id from the array to the map
  300. for (let i=0; i<a2.length; i++)
  301. map.set(a2[i].id, true);
  302. }
  303. //add to the new array all items that aren't included in the map (map lookUp is O(1) complexity)
  304. for(let i=0; i<arr1.length; i++)
  305. if (!map.get(arr1[i].id))
  306. out.push(arr1[i]);
  307. return out;
  308. }
  309. //})
  310.  
  311. /**
  312. * Get an Array sort function with multiple subarray fields.
  313. * Prefix letter allows specifying whether to sort ascending "+" or descending "-" (default: ascending)
  314. * Splits and recurses properties properly on periods
  315. * e.g.
  316. * let arr = [{obj:{prop1:'1'}, prop2: 3},{obj:{prop1:'3'}, prop2: 4},{obj:{prop1:'3'}, prop2: 2},{obj:{prop1:'4'}, prop2: 2}];
  317. * arr.sort(get_sort_method('+prop2','-obj.prop1'));
  318. * // [{obj:{prop1:'4'}, prop2: 2},{obj:{prop1:'3'}, prop2: 2},{obj:{prop1:'1'}, prop2: 3},{obj:{prop1:'3'}, prop2: 4}]
  319. */
  320. function get_sort_method(){
  321. let argsArr = Array.prototype.slice.call(arguments);
  322. return function(a, b){
  323. for(let x in argsArr){
  324. let strStart = 1;
  325. let op = argsArr[x].substring(0,1);
  326. if (op !== "-" && op !== "+") {op = "+";strStart = 0;}
  327. let prop = argsArr[x].substring(strStart);
  328. prop = prop.split('.');
  329. let len = prop.length;
  330. let i = 0;
  331. let ax = a;
  332. let bx = b;
  333. let cx;
  334. while(i<len) {ax = ax[prop[i]]; bx = bx[prop[i]]; i++;}
  335. ax = typeof ax == "string" ? ax.toLowerCase() : ax / 1;
  336. bx = typeof bx == "string" ? bx.toLowerCase() : bx / 1;
  337. if(op === "-"){cx = ax; ax = bx; bx = cx;}
  338. if(ax !== bx){return ax < bx ? -1 : 1;}
  339. }
  340. }
  341. }
  342.  
  343. function add_dates_to_items(items) {
  344. for (let i=0; i<items.length; i++) {
  345. let itm = items[i];
  346. itm.earliest_study_date = itm.assignments && itm.assignments.unlocked_at ? new Date(itm.assignments.started_at ? itm.assignments.available_at : itm.assignments.unlocked_at) : get_earliest_unlock_date(itm);
  347. itm.earliest_guru_date = get_item_guru_date(itm);
  348. }
  349. }
  350.  
  351. /*
  352. function get_accessible_items(items) {
  353. let output_items = [];
  354. for (let i=0; i<items.length; i++) {
  355. let itm = items[i];
  356. if (itm.assignments)
  357. output_items.push(itm);
  358. }
  359. return output_items;
  360. }
  361.  
  362. function get_inaccessible_items(items) {
  363. let output_items = [];
  364. for (let i=0; i<items.length; i++) {
  365. let itm = items[i];
  366. if (!itm.assignments)
  367. output_items.push(itm);
  368. }
  369. return output_items;
  370. }
  371. */
  372.  
  373. function get_not_passed_items(items) {
  374. let output_items = [];
  375. for (let i=0; i<items.length; i++) {
  376. let itm = items[i];
  377. if (!itm.assignments || !itm.assignments.passed_at)
  378. output_items.push(itm);
  379. }
  380. return output_items;
  381. }
  382.  
  383. function get_not_locked_but_not_passed_items(items) {
  384. let output_items = [];
  385. for (let i=0; i<items.length; i++) {
  386. let itm = items[i];
  387. if (itm.assignments && itm.assignments.unlocked_at && !itm.assignments.passed_at)
  388. output_items.push(itm);
  389. }
  390. return output_items;
  391. }
  392.  
  393. function get_not_passed_items_with_available_assignments(items) {
  394. let output_items = [];
  395. for (let i=0; i<items.length; i++) {
  396. let itm = items[i];
  397. if (itm.assignments && !itm.assignments.passed_at && itm.earliest_study_date.getTime()<=lu_obj.load_time.getTime())
  398. output_items.push(itm);
  399. }
  400. return output_items;
  401. }
  402.  
  403. function get_locked_items(items) {
  404. let output_items = [];
  405. for (let i=0; i<items.length; i++) {
  406. let itm = items[i];
  407. if (!itm.assignments || !itm.assignments.unlocked_at)
  408. output_items.push(itm);
  409. }
  410. return output_items;
  411. }
  412.  
  413. /*
  414. function get_unlocked_items(items) {
  415. let output_items = [];
  416. for (let i=0; i<items.length; i++) {
  417. let itm = items[i];
  418. if (itm.assignments && itm.assignments.unlocked_at)
  419. output_items.push(itm);
  420. }
  421. return output_items;
  422. }
  423. */
  424.  
  425. function get_next_soonest_study_items(items) {
  426. return items.reduce((acc,itm)=>{
  427. let min_date = acc.length>0 ? acc[0].earliest_study_date : itm.earliest_study_date;
  428. if (itm.earliest_study_date.getTime() > lu_obj.load_time.getTime() && itm.earliest_study_date.getTime() < min_date.getTime()) {
  429. min_date = itm.earliest_study_date;
  430. acc.length = 0;
  431. }
  432. if (itm.earliest_study_date.getTime() <= lu_obj.load_time.getTime() || itm.earliest_study_date.getTime() === min_date.getTime())
  433. acc.push(itm);
  434. itm.earliest_guru_date = get_item_guru_date(itm);
  435. return acc;
  436. },[]);
  437. }
  438.  
  439. /**
  440. * Gets the earliest date that the provided item can be unlocked if all components are gurued as soon as possible
  441. */
  442. function get_earliest_unlock_date(item) {
  443. let min_date;
  444. for (let rad_idx = 0; rad_idx < item.data.component_subject_ids.length; rad_idx++) {
  445. let rad = items_by_subject_id[item.data.component_subject_ids[rad_idx]];
  446. if (!rad) continue;
  447. if (!min_date || rad.earliest_guru_date.getTime() > min_date.getTime())
  448. min_date = new Date(rad.earliest_guru_date);
  449. }
  450. return min_date;
  451. }
  452.  
  453. /**
  454. * Get item guru date
  455. */
  456. function get_item_guru_date(item){
  457. //Calculate days to guru
  458. let hours_to_guru = 0;
  459. if (!item)
  460. hours_to_guru = 4+8+23+47;
  461. else if (!item.earliest_study_date && (!item.assignments || !item.assignments.unlocked_at)) {
  462. return new Date(0);
  463. }
  464. else {
  465. if (item.assignments && item.assignments.passed_at) return new Date(item.assignments.passed_at);
  466. switch (item.assignments ? item.assignments.srs_stage : 0) {
  467. case 0: hours_to_guru += 4+8+23+47; break;
  468. case 1: hours_to_guru += 8+23+47; break;
  469. case 2: hours_to_guru += 23+47; break;
  470. case 3: hours_to_guru += 47; break;
  471. }
  472. }
  473. //Add the hours to the available or current date if item is locked
  474. let earliest_guru_date = item.earliest_study_date ? new Date(item.earliest_study_date) : (item.assignments ? new Date(item.assignments.available_at ? item.assignments.available_at : item.assignments.unlocked_at) : new Date(lu_obj.load_time));
  475. if (earliest_guru_date.getTime() < lu_obj.load_time.getTime())
  476. earliest_guru_date = new Date(lu_obj.load_time);
  477. earliest_guru_date.setHours(earliest_guru_date.getHours() + hours_to_guru);
  478. return earliest_guru_date;
  479. }
  480.  
  481. function get_level_up_date() {
  482. let kanji_items = items_by_type['kanji'].sort((a,b)=>a.earliest_guru_date.getTime()-b.earliest_guru_date.getTime());
  483. return new Date(kanji_items[Math.ceil(kanji_items.length * 0.9)-1].earliest_guru_date);
  484. }
  485.  
  486. })(window.lu);