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-05 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Wanikani Level-Up Time Assistant
  3. // @namespace https://greasyfork.org/en/users/11878
  4. // @version 1.2.4
  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.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-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?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. // Cache these filters for quick lookups
  119. lu_obj.items_not_locked_and_not_passed = get_not_locked_but_not_passed_items(lu_obj.items);
  120. lu_obj.items_not_passed_with_assignments_available = get_not_passed_items_with_available_assignments(lu_obj.items);
  121. lu_obj.items_with_soonest_assignments = get_next_soonest_study_items(lu_obj.items_not_locked_and_not_passed);
  122.  
  123. // Log the results to the console
  124. log_base_items_stats();
  125. // Prevent non-intentional logging
  126. config.log.enabled = false;
  127. process_items();
  128. setup_next_reviews_callback();
  129. }
  130.  
  131. function process_items() {
  132. lu_obj.level_up_date = get_level_up_date();
  133. update_ui();
  134. let lu_level_up_date = document.getElementById('lu_level_up_date');
  135. let lu_arrow_up = document.getElementById('lu_arrow_up');
  136. if (lu_arrow_up) {
  137. lu_arrow_up.onmouseover = function(){update_ui('lu_arrow_up');};
  138. }
  139. if (lu_level_up_date) {
  140. lu_level_up_date.onmouseover = function(){update_ui('lu_level_up_date');};
  141. lu_level_up_date.onclick = function(){config.log.enabled = true; fetch_items();};
  142. }
  143. }
  144.  
  145. function update_date_title() {
  146. let lu_level_up_date = document.getElementById('lu_level_up_date');
  147. if (!lu_level_up_date) return;
  148. let dateOutput = format_date_to_standard_output(lu_obj.level_up_date, false);
  149. let wait_time = format_two_dates_diff_to_minimal_output(lu_obj.level_up_date,lu_obj.load_time, true);
  150. lu_level_up_date.innerHTML = dateOutput;
  151. lu_level_up_date.title = (wait_time==='Now'?'Available now':wait_time)+'\nClick to update data (and repeat logging to console)';
  152. }
  153.  
  154. function update_arrow_title() {
  155. let lu_arrow_up = document.getElementById('lu_arrow_up');
  156. if (!lu_arrow_up) return;
  157. let title = '';
  158. if (lu_obj.items_with_soonest_assignments.length>0) {
  159. let review_time = format_date_to_standard_output(lu_obj.items_with_soonest_assignments[0].earliest_study_date,true, true);
  160. let next_items = lu_obj.items_with_soonest_assignments;
  161. if (review_time === 'Now') {
  162. 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`;
  163. next_items = unique_from(get_next_soonest_study_items(lu_obj.items_not_locked_and_not_passed,lu_obj.items_with_soonest_assignments));
  164. if (next_items.length>0) {
  165. title += '\n\n';
  166. review_time = format_date_to_standard_output(next_items[0].earliest_study_date,true, true);
  167. }
  168. }
  169. if (next_items.length>0) {
  170. 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)}`;
  171. }
  172. } else {
  173. title = get_text_for_icon_tooltip();
  174. }
  175. lu_arrow_up.title = title;
  176. if (lu_obj.items_not_passed_with_assignments_available.length>0) {
  177. let destination = lu_obj.items_not_passed_with_assignments_available.find(a=>!a.assignments.available_at) ? 'lesson' : 'review';
  178. lu_arrow_up.onclick = function(){window.location='https://www.wanikani.com/subjects/'+destination;};
  179. lu_arrow_up.style.setProperty('color', '#00ff00');
  180. lu_arrow_up.classList.add("fa-beat");
  181. } else {
  182. lu_arrow_up.onclick = null;
  183. lu_arrow_up.style.removeProperty('color');
  184. lu_arrow_up.classList.remove("fa-beat");
  185. }
  186. }
  187.  
  188. function update_ui(element_id) {
  189. lu_obj.load_time = new Date();
  190. if (!element_id || element_id === 'lu_level_up_date')
  191. update_date_title();
  192. if (!element_id || element_id === 'lu_arrow_up')
  193. update_arrow_title();
  194. }
  195.  
  196. function setup_next_reviews_callback() {
  197. if (config.callback) {
  198. clearTimeout(config.callback);
  199. config.callback = null;
  200. }
  201. if (lu_obj.items_with_soonest_assignments.length<=0) return;
  202. let time_diff = lu_obj.items_with_soonest_assignments[0].earliest_study_date.getTime() - (new Date()).getTime();
  203. if (time_diff <= 0) return;
  204. setTimeout(function() {
  205. config.callback = null;
  206. fetch_items();
  207. }, time_diff);
  208. }
  209.  
  210. function log_base_items_stats() {
  211. if (!config.log.enabled) return;
  212. const max_date_len = 48;
  213. let items_not_passed_by_type = wkof.ItemData.get_index(get_not_passed_items(lu_obj.items), 'item_type');
  214. let items_locked_by_type = wkof.ItemData.get_index(get_locked_items(lu_obj.items), 'item_type');
  215. for (let itype of Object.keys(items_not_passed_by_type).sort((a,b)=>a.localeCompare(b)*-1)) {
  216. let not_passed_items = items_not_passed_by_type[itype];
  217. if (!not_passed_items || not_passed_items.length<=0) continue;
  218. let str = [`${not_passed_items.length} remaining ${itype}${itype==='radical'&&not_passed_items.length>1?'s':''} to guru`+
  219. (items_locked_by_type[itype]&&items_locked_by_type[itype].length>0?' ('+items_locked_by_type[itype].length+' of which are still locked)':'')];
  220. if (config.log.detailed) {
  221. for (let i=0; i<not_passed_items.length; i++) {
  222. let itm = not_passed_items[i];
  223. let next_study_time = format_date_to_standard_output(itm.earliest_study_date,true);
  224. let earliest_guru_time = format_date_to_standard_output(itm.earliest_guru_date,true);
  225. str.push((!itm.assignments||!itm.assignments.unlocked_at?'🔒 ':'')+(itm.data.characters?itm.data.characters:itm.data.slug)+'\t'+
  226. '| Stage: '+(itm.assignments?itm.assignments.srs_stage:0)+'/5\t'+
  227. '| Next Study: '+next_study_time+'\t'.repeat(Math.ceil((max_date_len-next_study_time.length)/8))+
  228. '| Earliest Guru: '+earliest_guru_time+'\t'.repeat(Math.ceil((max_date_len-earliest_guru_time.length)/8))+
  229. '| '+itm.data.document_url);
  230. }
  231. }
  232. console.log(str.join('\n'));
  233. }
  234. }
  235.  
  236. //========================================================================
  237. // Formatting
  238. //-------------------------------------------------------------------
  239.  
  240. function get_text_for_icon_tooltip() {
  241. 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`;
  242. }
  243.  
  244. function format_two_dates_diff_to_minimal_output(date, date2, include_seconds) {
  245. let diff = Math.max(0, Math.trunc(date.getTime()/1000)-Math.trunc(date2.getTime()/1000));
  246. let dd = Math.floor(diff / 86400);
  247. diff -= dd*86400;
  248. let hh = Math.floor(diff / 3600);
  249. diff -= hh*3600;
  250. let mm = Math.floor(diff / 60);
  251. diff -= mm*60;
  252. let ss = diff;
  253. if (dd > 0) {
  254. return dd+' day'+(dd===1?'':'s')+', '+hh+' hour'+(hh===1?'':'s')+', '+mm+' min'+(mm===1?'':'s')+(include_seconds?', '+ss+' sec'+(ss===1?'':'s'):'');
  255. } else if (hh > 0) {
  256. return hh+' hour'+(hh===1?'':'s')+', '+mm+' min'+(mm===1?'':'s')+(include_seconds?', '+ss+' sec'+(ss===1?'':'s'):'');
  257. } else if (mm > 0 || ss > 0) {
  258. if (!include_seconds && ss > 30) mm++;
  259. return mm+' min'+(mm===1?'':'s')+(include_seconds?', '+ss+' sec'+(ss===1?'':'s'):'');
  260. } else {
  261. return 'Now';
  262. }
  263. }
  264.  
  265. function format_date_to_standard_output(date, include_differential, include_seconds) {
  266. if (!(date instanceof Date)) date = new Date(date);
  267. if (date.getTime() <= lu_obj.load_time.getTime()) return "Now";
  268. let str = date.toLocaleString([],{weekday:"short",month:"short",day:"numeric",hour12:false,hour:"numeric",minute:"numeric"});
  269. if (!include_differential) return str;
  270. return str +' ('+format_two_dates_diff_to_minimal_output(date,lu_obj.load_time, include_seconds)+')';
  271. }
  272.  
  273. //========================================================================
  274. // Transformers and Helpers
  275. //-------------------------------------------------------------------
  276.  
  277. /**
  278. * Returns a non-destructive Array of elements that are not found in
  279. * any of the parameter arrays.
  280. * Assumes all items have a property named "id"
  281. *
  282. * @param {...Array} var_args Arrays to compare.
  283. */
  284. function unique_from(arr1, ...args) {
  285. if (!args.length) return [];
  286. let out = [];
  287. let map = new Map();
  288. for (let n=0; n < args.length; n++) {
  289. let a2 = args[n];
  290. if (!(a2 instanceof Array))
  291. throw new TypeError( 'argument ['+n+'] must be an Array' );
  292. // Add existing id from the array to the map
  293. for (let i=0; i<a2.length; i++)
  294. map.set(a2[i].id, true);
  295. }
  296. // Add to the new array all items that aren't included in the map (map lookUp is O(1) complexity)
  297. for(let i=0; i<arr1.length; i++)
  298. if (!map.get(arr1[i].id))
  299. out.push(arr1[i]);
  300. return out;
  301. }
  302.  
  303. /**
  304. * Get an Array sort function with multiple subarray fields.
  305. * Prefix letter allows specifying whether to sort ascending "+" or descending "-" (default: ascending)
  306. * Splits and recurses properties properly on periods
  307. * e.g.
  308. * let arr = [{obj:{prop1:'1'}, prop2: 3},{obj:{prop1:'3'}, prop2: 4},{obj:{prop1:'3'}, prop2: 2},{obj:{prop1:'4'}, prop2: 2}];
  309. * arr.sort(get_sort_method('+prop2','-obj.prop1'));
  310. * // [{obj:{prop1:'4'}, prop2: 2},{obj:{prop1:'3'}, prop2: 2},{obj:{prop1:'1'}, prop2: 3},{obj:{prop1:'3'}, prop2: 4}]
  311. */
  312. function get_sort_method(){
  313. let argsArr = Array.prototype.slice.call(arguments);
  314. return function(a, b){
  315. for(let x in argsArr){
  316. let strStart = 1;
  317. let op = argsArr[x].substring(0,1);
  318. if (op !== "-" && op !== "+") {op = "+";strStart = 0;}
  319. let prop = argsArr[x].substring(strStart);
  320. prop = prop.split('.');
  321. let len = prop.length;
  322. let i = 0;
  323. let ax = a;
  324. let bx = b;
  325. let cx;
  326. while(i<len) {ax = ax[prop[i]]; bx = bx[prop[i]]; i++;}
  327. ax = typeof ax == "string" ? ax.toLowerCase() : ax / 1;
  328. bx = typeof bx == "string" ? bx.toLowerCase() : bx / 1;
  329. if(op === "-"){cx = ax; ax = bx; bx = cx;}
  330. if(ax !== bx){return ax < bx ? -1 : 1;}
  331. }
  332. };
  333. }
  334.  
  335. function add_dates_to_items(items) {
  336. for (let i=0; i<items.length; i++) {
  337. let itm = items[i];
  338. 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);
  339. itm.earliest_guru_date = get_item_guru_date(itm);
  340. }
  341. }
  342.  
  343. function get_not_passed_items(items) {
  344. let output_items = [];
  345. for (let i=0; i<items.length; i++) {
  346. let itm = items[i];
  347. if (!itm.assignments || !itm.assignments.passed_at)
  348. output_items.push(itm);
  349. }
  350. return output_items;
  351. }
  352.  
  353. function get_not_locked_but_not_passed_items(items) {
  354. let output_items = [];
  355. for (let i=0; i<items.length; i++) {
  356. let itm = items[i];
  357. if (itm.assignments && itm.assignments.unlocked_at && !itm.assignments.passed_at)
  358. output_items.push(itm);
  359. }
  360. return output_items;
  361. }
  362.  
  363. function get_not_passed_items_with_available_assignments(items) {
  364. let output_items = [];
  365. for (let i=0; i<items.length; i++) {
  366. let itm = items[i];
  367. if (itm.assignments && !itm.assignments.passed_at && itm.earliest_study_date.getTime()<=lu_obj.load_time.getTime())
  368. output_items.push(itm);
  369. }
  370. return output_items;
  371. }
  372.  
  373. function get_locked_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.unlocked_at)
  378. output_items.push(itm);
  379. }
  380. return output_items;
  381. }
  382.  
  383. function get_next_soonest_study_items(items) {
  384. return items.reduce((acc,itm)=>{
  385. let min_date = acc.length>0 ? acc[0].earliest_study_date : itm.earliest_study_date;
  386. if (itm.earliest_study_date.getTime() > lu_obj.load_time.getTime() && itm.earliest_study_date.getTime() < min_date.getTime()) {
  387. min_date = itm.earliest_study_date;
  388. acc.length = 0;
  389. }
  390. if (itm.earliest_study_date.getTime() <= lu_obj.load_time.getTime() || itm.earliest_study_date.getTime() === min_date.getTime())
  391. acc.push(itm);
  392. itm.earliest_guru_date = get_item_guru_date(itm);
  393. return acc;
  394. },[]);
  395. }
  396.  
  397. /**
  398. * Gets the earliest date that the provided item can be unlocked if all components are gurued as soon as possible
  399. */
  400. function get_earliest_unlock_date(item) {
  401. let min_date;
  402. for (let rad_idx = 0; rad_idx < item.data.component_subject_ids.length; rad_idx++) {
  403. let rad = items_by_subject_id[item.data.component_subject_ids[rad_idx]];
  404. if (!rad) continue;
  405. if (!min_date || rad.earliest_guru_date.getTime() > min_date.getTime())
  406. min_date = new Date(rad.earliest_guru_date);
  407. }
  408. return min_date;
  409. }
  410.  
  411. /**
  412. * Calculate item guru date
  413. */
  414. function get_item_guru_date(item){
  415. let hours_to_guru = 0;
  416. if (!item || !item.earliest_study_date && (!item.assignments || !item.assignments.unlocked_at))
  417. return new Date(0); // This is mostly for debugging. If you see the 12/31/1969 date anywhere, this is where it went wrong.
  418. if (item.assignments && item.assignments.passed_at)
  419. return new Date(item.assignments.passed_at);
  420. switch (item.assignments ? item.assignments.srs_stage : 0) {
  421. case 0: hours_to_guru += 4+8+23+47; break;
  422. case 1: hours_to_guru += 8+23+47; break;
  423. case 2: hours_to_guru += 23+47; break;
  424. case 3: hours_to_guru += 47; break;
  425. }
  426. // Add the hours to the available date, or the unlock date if the item is locked, or current date something went wrong
  427. 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));
  428. if (earliest_guru_date.getTime() < lu_obj.load_time.getTime())
  429. earliest_guru_date = new Date(lu_obj.load_time);
  430. earliest_guru_date.setHours(earliest_guru_date.getHours() + hours_to_guru);
  431. return earliest_guru_date;
  432. }
  433.  
  434. /**
  435. * Get earliest possible level up date
  436. * Calculated by sorting the kanji by earliest possible guru date and taking the time from the 90% to last kanji
  437. */
  438. function get_level_up_date() {
  439. let kanji_items = items_by_type.kanji.sort((a,b)=>a.earliest_guru_date.getTime()-b.earliest_guru_date.getTime());
  440. return new Date(kanji_items[Math.ceil(kanji_items.length * 0.9)-1].earliest_guru_date);
  441. }
  442.  
  443. })(window.lu);