Wanikani Wrap-up Button Enhancement (Jerky Edition)

Beefed-up Wrap-up button (Jerky Edition)

  1. // ==UserScript==
  2. // @name Wanikani Wrap-up Button Enhancement (Jerky Edition)
  3. // @namespace https://www.wanikani.com
  4. // @version 5.1.2
  5. // @description Beefed-up Wrap-up button (Jerky Edition)
  6. // @author Inserio (Orig. Mempo)
  7. // @match https://www.wanikani.com/*
  8. // @grant none
  9. // @license MIT
  10. // ==/UserScript==
  11. /* global Stimulus */
  12. /* jshint esversion: 11 */
  13.  
  14. (function() {
  15. 'use strict';
  16.  
  17. // ========================================================================
  18. // Globals
  19. const scriptId = 'wrap-up-amount', menuId = `${scriptId}-menu`, filterId = `${scriptId}-filter`, filterContainerId = `${filterId}-container`,
  20. filterIconId = `${filterId}-icon`, filterIconContainerId = `${filterIconId}-container`, inputNumberId = `${scriptId}-input`, listenerOpts = {passive: true};
  21. const state = {
  22. queue: {
  23. controller: null,
  24. count: 10 // default value from WaniKani
  25. },
  26. filter: {
  27. enabled: false,
  28. hasProcessed: false
  29. }
  30. };
  31.  
  32. // ========================================================================
  33. // Startup
  34.  
  35. installCSS();
  36. document.documentElement.addEventListener('turbo:load', () => { setTimeout(initUi, 0); }, listenerOpts);
  37.  
  38. // ========================================================================
  39. // Functions
  40.  
  41. /**
  42. * Install stylesheet.
  43. */
  44. function installCSS() {
  45. const head = document.getElementsByTagName('head')[0];
  46. if (head) {
  47. const style = document.createElement('style');
  48. style.setAttribute('id', scriptId);
  49. style.setAttribute('type', 'text/css');
  50. // language=CSS
  51. style.textContent = `
  52. li#${menuId} {
  53. display: flex;
  54. align-items: center;
  55. }
  56. li#${menuId} * {
  57. text-align: center;
  58. }
  59. li#${menuId} > * {
  60. flex: 1;
  61. min-width: 0;
  62. }`;
  63. head.insertAdjacentElement('beforeend', style);
  64. }
  65. }
  66.  
  67. /**
  68. * Initialize the user interface.
  69. */
  70. function initUi() {
  71. state.queue.controller = null;
  72. const wrapUpBox = document.getElementById('additional-content')?.querySelector('li:has(.additional-content__item--wrap-up)');
  73. if (!wrapUpBox) return Promise.resolve();
  74.  
  75. wrapUpBox.insertAdjacentHTML('afterend', `
  76. <li class="additional-content__menu-item additional-content__menu-item--5" id="${menuId}">
  77. <a class="additional-content__item additional-content__item--wrap-up-filter" id="${filterContainerId}" title="Filter Wrap Up to Only Started Items" data-wrap-up-count-class="additional-content__item-icon-text" data-wrap-up-active-class="additional-content__item--active" tabindex="0">
  78. <div class="additional-content__item-text">Filter</div>
  79. <div class="additional-content__item-icon-container" id="${filterIconContainerId}">
  80. <div class="additional-content__item-icon-text"></div>
  81. <div class="wk-icon wk-icon--scales" id="${filterIconId}" aria-hidden="true">🔗</div>
  82. </div>
  83. </a>
  84. <input class="additional-content__item additional-content__item--wrap-up-count" id="${inputNumberId}" title="Wrap Up Item Count" tabindex="0" type="number" min="1" max="${state.queue.count}" step="1" value="${state.queue.count}"></input>
  85. </li>`);
  86. document.getElementById(filterContainerId).addEventListener('click', onFilterButtonClick, listenerOpts);
  87. const inputNumber = document.getElementById(inputNumberId);
  88. inputNumber.addEventListener('input', onWrapUpValueChanged, listenerOpts);
  89. inputNumber.addEventListener('wheel', {}, {passive: false}); // hack to inherit default onwheel listener
  90.  
  91. return waitForController('quizQueue', 'quiz-queue').then(res=>{
  92. if (!('quizQueue' in res)) throw Error('Failed to access the quizQueue controller');
  93. state.queue.controller = res;
  94. document.getElementById(inputNumberId).max = res.quizQueue.totalItems;
  95. registerOnWrapUpListener();
  96. });
  97. }
  98.  
  99. function registerOnWrapUpListener() {
  100. let onRegistration = ({toggleWrap, deregisterObserver}) => {};
  101. let onUpdateCount = ({currentCount}) => {};
  102. let onWrapUp = ({isWrappingUp, currentCount}) => {
  103. onWrapUpFilterClicked(isWrappingUp);
  104. if (!state.filter.enabled && !state.filter.hasProcessed) {
  105. // if not using the filter, the queue count must be manually updated instead
  106. onWrapUpValueChanged();
  107. }
  108. };
  109. let registerWrapUpObserver = {
  110. onRegistration: onRegistration,
  111. onUpdateCount: onUpdateCount,
  112. onWrapUp: onWrapUp
  113. };
  114. window.dispatchEvent(new CustomEvent('registerWrapUpObserver', {detail: {observer: registerWrapUpObserver}}));
  115. }
  116.  
  117. function getControllerV1(name) { return Stimulus?.controllers.find(controller => controller[name]); }
  118. function getControllerV2(name) { return Stimulus?.getControllerForElementAndIdentifier(document.querySelector(`[data-controller~="${name}"]`),name); }
  119.  
  120. async function waitForController(nameV1, nameV2) {
  121. if (nameV1 == null && nameV2 == null) return Promise.reject();
  122. const controller = (nameV1 ? getControllerV1(nameV1) : null) ?? (nameV2 ? getControllerV2(nameV2) : null);
  123. if (controller) return controller;
  124. await new Promise(resolve => setTimeout(resolve, 1));
  125. return await waitForController(nameV1, nameV2);
  126. }
  127.  
  128. function onFilterButtonClick() {
  129. const newState = document.getElementById(filterContainerId)?.classList.toggle('additional-content__item--active');
  130. const inputContainer = document.getElementById(inputNumberId);
  131. inputContainer?.classList.toggle('additional-content__item--disabled', newState);
  132. inputContainer?.toggleAttribute('disabled', newState);
  133. state.filter.enabled = newState;
  134. onWrapUpFilterClicked(state.queue.controller?.quizQueue.wrapUpManager.wrappingUp);
  135. }
  136.  
  137. function onWrapUpValueChanged() {
  138. const element = document.getElementById(inputNumberId);
  139. const newQueueSize = getCustomWrapUpAmount(element);
  140. if (newQueueSize === null) {
  141. element.value = state.queue.count;
  142. return;
  143. }
  144. updateQueueCount(newQueueSize);
  145. }
  146.  
  147. function onWrapUpFilterClicked(isWrappingUp) {
  148. if (!state.queue.controller || !('quizQueue' in state.queue.controller)) return;
  149. const quizQueue = state.queue.controller.quizQueue;
  150. if (!isWrappingUp || (state.filter.hasProcessed && !state.filter.enabled)) { // revert queue modifications when no longer wrapping up
  151. if (!state.filter.hasProcessed) return; // nothing to revert
  152. const emptySlots = quizQueue.maxActiveQueueSize - quizQueue.activeQueue.length;
  153. quizQueue.activeQueue = quizQueue.activeQueue.concat(quizQueue.backlogQueue.slice(0, emptySlots));
  154. quizQueue.backlogQueue = quizQueue.backlogQueue.slice(emptySlots);
  155. quizQueue.fetchMoreItems();
  156. quizQueue.wrapUpManager.updateQueueSize(quizQueue.activeQueue.length);
  157. onWrapUpValueChanged();
  158. state.filter.hasProcessed = false;
  159. return;
  160. }
  161. if (!state.filter.enabled) return; // don't modify queue when filter is disabled
  162. const ids = Array.from(quizQueue.stats.data.entries().filter(([,{reading,meaning}])=>(!(reading.complete && meaning.complete))).map(([id])=>id));
  163. const newActiveQueue = [];
  164. const newBacklogQueue = [];
  165. for (const item of quizQueue.activeQueue) {
  166. if (ids.includes(item.id))
  167. newActiveQueue.push(item);
  168. else
  169. newBacklogQueue.push(item);
  170. }
  171. if (!newActiveQueue.includes(quizQueue.currentItem)) newActiveQueue.unshift(quizQueue.currentItem);
  172. newBacklogQueue.push(...quizQueue.backlogQueue);
  173. let index = -1;
  174. if ((index = newBacklogQueue.indexOf(quizQueue.currentItem)) > -1) newBacklogQueue.splice(index, 1);
  175. quizQueue.activeQueue = newActiveQueue;
  176. quizQueue.backlogQueue = newBacklogQueue;
  177. quizQueue.wrapUpManager.updateQueueSize(quizQueue.activeQueue.length);
  178. onWrapUpValueChanged();
  179. state.filter.hasProcessed = true;
  180. }
  181.  
  182. function getCustomWrapUpAmount(element) {
  183. if (!element || !element.value) return null;
  184. let amount = Number(element.value);
  185. if (Number.isNaN(amount) || (amount = parseInt(amount)) <= 0) return null;
  186. return state.queue.count = amount;
  187. }
  188.  
  189. function getQueueSizeDifference(newSize) {
  190. if (typeof newSize !== 'number' || !state.queue.controller || !('quizQueue' in state.queue.controller)) return 0;
  191. const quizQueue = state.queue.controller.quizQueue;
  192. if (newSize > quizQueue.totalItems)
  193. document.getElementById(inputNumberId).value = newSize = quizQueue.totalItems;
  194. if (!quizQueue.wrapUpManager.wrappingUp) return 0; // don't actually modify the queue if not currently wrappingUp
  195. if (state.filter.hasProcessed) return 0; // this shouldn't be necessary, but better to be safe
  196. return newSize - quizQueue.maxActiveQueueSize;
  197. }
  198.  
  199. function updateQueueCount(newSize) {
  200. const queueDifference = getQueueSizeDifference(newSize);
  201. const quizQueue = state.queue.controller.quizQueue;
  202. if (queueDifference === 0) return;
  203. // update the queue similar to how it is done in `onWrapUp({isWrappingUp})`
  204. // empty slots must be calculated because a user could have previously been in wrap up mode
  205. const emptySlots = quizQueue.maxActiveQueueSize - quizQueue.activeQueue.length;
  206.  
  207. quizQueue.maxActiveQueueSize = newSize;
  208. let sliceIndex;
  209. if (queueDifference > 0) {
  210. sliceIndex = emptySlots + queueDifference;
  211. quizQueue.activeQueue = quizQueue.activeQueue.concat(quizQueue.backlogQueue.slice(0, sliceIndex));
  212. quizQueue.backlogQueue = quizQueue.backlogQueue.slice(sliceIndex);
  213. quizQueue.fetchMoreItems();
  214. } else {
  215. sliceIndex = quizQueue.maxActiveQueueSize - emptySlots;
  216. quizQueue.backlogQueue = quizQueue.activeQueue.slice(sliceIndex).concat(quizQueue.backlogQueue);
  217. quizQueue.activeQueue = quizQueue.activeQueue.slice(0, sliceIndex);
  218. }
  219. quizQueue.wrapUpManager.updateQueueSize(quizQueue.activeQueue.length);
  220. }
  221.  
  222. })();