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

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