Wanikani Open Framework Turbo Events

Adds helpful methods for dealing with Turbo Events to WaniKani Open Framework

目前为 2024-08-06 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Wanikani Open Framework Turbo Events
  3. // @namespace https://greasyfork.org/en/users/11878
  4. // @description Adds helpful methods for dealing with Turbo Events to WaniKani Open Framework
  5. // @version 2.2.4
  6. // @match https://www.wanikani.com/*
  7. // @match https://preview.wanikani.com/*
  8. // @author Inserio
  9. // @copyright 2024, Brian Shenk
  10. // @license MIT; http://opensource.org/licenses/MIT
  11. // @run-at document-start
  12. // @grant none
  13. // ==/UserScript==
  14. /* global wkof */
  15. /* jshint esversion: 11 */
  16.  
  17. (function() {
  18. 'use strict';
  19.  
  20. const version = '2.2.4';
  21. const turboPrefix = 'turbo:', listenerOptions = {capture: true, once: false, passive: true, signal: undefined}, persistent = false;
  22. const handleDetailFetchResponseResponseUrl = {listener: async event => await handleEvent(event, event.detail.fetchResponse.response.url), listenerOptions, persistent},
  23. handleDetailFormSubmissionFetchRequestUrlHref = {listener: async event => await handleEvent(event, event.detail.formSubmission.fetchRequest.url.href), listenerOptions, persistent},
  24. handleDetailNewElementBaseURI = {listener: async event => await handleEvent(event, event.detail.newElement.baseURI), listenerOptions, persistent},
  25. handleDetailNewFrameBaseURI = {listener: async event => await handleEvent(event, event.detail.newFrame.baseURI), listenerOptions, persistent},
  26. handleDetailNewStreamUrl = {listener: async event => await handleEvent(event, event.detail.newStream.url), listenerOptions, persistent},
  27. handleDetailRequestUrlHref = {listener: async event => await handleEvent(event, event.detail.request.url.href), listenerOptions, persistent},
  28. handleDetailResponseUrl = {listener: async event => await handleEvent(event, event.detail.response.url), listenerOptions, persistent},
  29. handleDetailUrl = {listener: async event => await handleEvent(event, event.detail.url), listenerOptions, persistent},
  30. handleDetailUrlAndUpdateLoadedPage = {listener: async event => await handleEvent(event, lastUrlLoaded = event.detail.url), listenerOptions, persistent: true},
  31. handleDetailUrlHref = {listener: async event => await handleEvent(event, event.detail.url.href), listenerOptions, persistent},
  32. handleTargetBaseURI = {listener: async event => await handleEvent(event, event.target.baseURI), listenerOptions, persistent},
  33. handleTargetHref = {listener: async event => await handleEvent(event, event.target.href), listenerOptions, persistent};
  34. // https://turbo.hotwired.dev/reference/events
  35. const turboEvents = deepFreeze({
  36. click: {source: 'document', name: `${turboPrefix}click`, handler: handleDetailUrl},
  37. before_visit: {source: 'document', name: `${turboPrefix}before-visit`, handler: handleDetailUrl},
  38. visit: {source: 'document', name: `${turboPrefix}visit`, handler: handleDetailUrl},
  39. before_cache: {source: 'document', name: `${turboPrefix}before-cache`, handler: handleTargetBaseURI},
  40. before_render: {source: 'document', name: `${turboPrefix}before-render`, handler: handleTargetBaseURI},
  41. render: {source: 'document', name: `${turboPrefix}render`, handler: handleTargetBaseURI},
  42. load: {source: 'document', name: `${turboPrefix}load`, handler: handleDetailUrlAndUpdateLoadedPage},
  43. morph: {source: 'pageRefresh', name: `${turboPrefix}morph`, handler: handleDetailNewElementBaseURI},
  44. before_morph_element: {source: 'pageRefresh', name: `${turboPrefix}before-morph-element`, handler: handleTargetBaseURI},
  45. before_morph_attribute: {source: 'pageRefresh', name: `${turboPrefix}before-morph-attribute`, handler: handleDetailNewElementBaseURI},
  46. morph_element: {source: 'pageRefresh', name: `${turboPrefix}morph-element`, handler: handleDetailNewElementBaseURI},
  47. submit_start: {source: 'forms', name: `${turboPrefix}submit-start`, handler: handleDetailFormSubmissionFetchRequestUrlHref},
  48. submit_end: {source: 'forms', name: `${turboPrefix}submit-end`, handler: handleDetailFetchResponseResponseUrl},
  49. before_frame_render: {source: 'frames', name: `${turboPrefix}before-frame-render`, handler: handleDetailNewFrameBaseURI},
  50. frame_render: {source: 'frames', name: `${turboPrefix}frame-render`, handler: handleTargetBaseURI},
  51. frame_load: {source: 'frames', name: `${turboPrefix}frame-load`, handler: handleTargetBaseURI},
  52. frame_missing: {source: 'frames', name: `${turboPrefix}frame-missing`, handler: handleDetailResponseUrl},
  53. before_stream_render: {source: 'streams', name: `${turboPrefix}before-stream-render`, handler: handleDetailNewStreamUrl},
  54. before_fetch_request: {source: 'httpRequests', name: `${turboPrefix}before-fetch-request`, handler: handleDetailUrlHref},
  55. before_fetch_response: {source: 'httpRequests', name: `${turboPrefix}before-fetch-response`, handler: handleDetailFetchResponseResponseUrl},
  56. before_prefetch: {source: 'httpRequests', name: `${turboPrefix}before-prefetch`, handler: handleTargetHref},
  57. fetch_request_error: {source: 'httpRequests', name: `${turboPrefix}fetch-request-error`, handler: handleDetailRequestUrlHref},
  58. });
  59. const turboListeners = Object.freeze({
  60. before_cache: (callback, options) => addEventListener(turboEvents.before_cache.name, callback, options),
  61. before_fetch_request: (callback, options) => addEventListener(turboEvents.before_fetch_request.name, callback, options),
  62. before_fetch_response: (callback, options) => addEventListener(turboEvents.before_fetch_response.name, callback, options),
  63. before_frame_render: (callback, options) => addEventListener(turboEvents.before_frame_render.name, callback, options),
  64. before_morph_attribute: (callback, options) => addEventListener(turboEvents.before_morph_attribute.name, callback, options),
  65. before_morph_element: (callback, options) => addEventListener(turboEvents.before_morph_element.name, callback, options),
  66. before_prefetch: (callback, options) => addEventListener(turboEvents.before_prefetch.name, callback, options),
  67. before_render: (callback, options) => addEventListener(turboEvents.before_render.name, callback, options),
  68. before_stream_render: (callback, options) => addEventListener(turboEvents.before_stream_render.name, callback, options),
  69. before_visit: (callback, options) => addEventListener(turboEvents.before_visit.name, callback, options),
  70. click: (callback, options) => addEventListener(turboEvents.click.name, callback, options),
  71. fetch_request_error: (callback, options) => addEventListener(turboEvents.fetch_request_error.name, callback, options),
  72. frame_load: (callback, options) => addEventListener(turboEvents.frame_load.name, callback, options),
  73. frame_missing: (callback, options) => addEventListener(turboEvents.frame_missing.name, callback, options),
  74. frame_render: (callback, options) => addEventListener(turboEvents.frame_render.name, callback, options),
  75. load: (callback, options) => addEventListener(turboEvents.load.name, callback, options),
  76. morph: (callback, options) => addEventListener(turboEvents.morph.name, callback, options),
  77. morph_element: (callback, options) => addEventListener(turboEvents.morph_element.name, callback, options),
  78. render: (callback, options) => addEventListener(turboEvents.render.name, callback, options),
  79. submit_end: (callback, options) => addEventListener(turboEvents.submit_end.name, callback, options),
  80. submit_start: (callback, options) => addEventListener(turboEvents.submit_start.name, callback, options),
  81. visit: (callback, options) => addEventListener(turboEvents.visit.name, callback, options),
  82. });
  83. const common = Object.defineProperties({},{
  84. locations: {value: Object.defineProperties({}, {
  85. dashboard: {value: /^https:\/\/www\.wanikani\.com(\/dashboard.*)?\/?$/},
  86. items_pages: {value: /^https:\/\/www\.wanikani\.com\/(radicals|kanji|vocabulary)\/.+\/?$/},
  87. lessons: {value: /^https:\/\/www\.wanikani\.com\/subject-lessons\/(start|[\d-]+\/\d+)\/?$/},
  88. lessons_picker: {value: /^https:\/\/www\.wanikani\.com\/subject-lessons\/picker\/?$/},
  89. lessons_quiz: {value: /^https:\/\/www\.wanikani\.com\/subject-lessons\/[\d-]+\/quiz.*\/?$/},
  90. reviews: {value: /^https:\/\/www\.wanikani\.com\/subjects\/review.*\/?$/},
  91. }),
  92. }}), commonListeners = Object.defineProperties({},{
  93. events: {value: (eventList, callback, options) => addMultipleEventListeners(eventList, callback, options)},
  94. urls: {value: (callback, urls) => addTypicalPageListener(callback, urls)},
  95. targetIds: {value: (callback, targetIds) => addTypicalFrameListener(callback, targetIds)},
  96. dashboard: {value: callback => addTypicalPageListener(callback, common.locations.dashboard)},
  97. items_pages: {value: callback => addTypicalPageListener(callback, common.locations.items_pages)},
  98. lessons: {value: callback => addTypicalPageListener(callback, common.locations.lessons)},
  99. lessons_picker: {value: callback => addTypicalPageListener(callback, common.locations.lessons_picker)},
  100. lessons_quiz: {value: callback => addTypicalPageListener(callback, common.locations.lessons_quiz)},
  101. reviews: {value: callback => addTypicalPageListener(callback, common.locations.reviews)},
  102. }), eventMap = Object.defineProperties({}, {
  103. common: {value: commonListeners},
  104. event: {value: turboListeners},
  105. });
  106. const eventHandlers = {}, internalHandlers = {};
  107. const publishedInterface = Object.freeze({
  108. add_event_listener: addEventListener,
  109. remove_event_listener: removeEventListener,
  110. on: eventMap,
  111. events: turboEvents,
  112. common: common,
  113. version,
  114. '_.internal': {internalHandlers, eventHandlers}
  115. });
  116. let lastUrlLoaded = '!';
  117.  
  118. /**
  119. * Listeners
  120. */
  121.  
  122. // Sets up a function that will be called whenever the specified event is delivered to the target.
  123. function addEventListener(eventName, listener, options) {
  124. if (listener === undefined || listener === null) return false;
  125. eventName = getValidEventName(eventName);
  126. if (eventName === null) return false;
  127.  
  128. if (eventName === 'load') {
  129. if (typeof listener !== 'function' || lastUrlLoaded === '!' || !verifyOptions('load', lastUrlLoaded, Object.assign({checkDocumentIds: true}, options))) return false;
  130. listener('load', lastUrlLoaded);
  131. return true;
  132. }
  133. const eventKey = eventName.slice(turboPrefix.length).replaceAll('-', '_');
  134. if (!(eventKey in turboEvents)) return false;
  135. if (!internalHandlers[eventName]?.active) addInternalEventListener(eventName, turboEvents[eventKey].handler, true);
  136. if (!(eventName in eventHandlers)) eventHandlers[eventName] = new Map();
  137. eventHandlers[eventName].set(listener, options);
  138. return true;
  139. }
  140.  
  141. function addInternalEventListener(eventName, handler, activate) {
  142. if (typeof eventName !== 'string') return false;
  143. let internalHandler;
  144. if (eventName in internalHandlers && internalHandlers[eventName].handler.listenerOptions === handler.listenerOptions)
  145. internalHandler = internalHandlers[eventName];
  146. else internalHandler = internalHandlers[eventName] = {handler, active: false};
  147.  
  148. if (activate && !internalHandler.active) {
  149. document.documentElement.addEventListener(eventName, handler.listener, handler.listenerOptions);
  150. internalHandler.active = true;
  151. }
  152. return true;
  153. }
  154.  
  155. function addMultipleEventListeners(eventList, callback, options) {
  156. if (eventList === turboEvents) eventList = Object.values(eventList);
  157. if (Array.isArray(eventList)) {
  158. return eventList.map(eventName => {
  159. const name = getValidEventName(eventName);
  160. return {name, added: addEventListener(name, callback, options)};
  161. });
  162. } else {
  163. const name = getValidEventName(eventList);
  164. return {name, added: addEventListener(name, callback, options)};
  165. }
  166. }
  167.  
  168. // Add a typical listener to run for the provided urls.
  169. function addTypicalPageListener(callback, urls) {
  170. return commonListeners.events(['load', turboEvents.load.name], callback, {urls});
  171. }
  172.  
  173. // Add a typical listener to run for the provided urls.
  174. function addTypicalFrameListener(callback, targetIds) {
  175. return turboListeners.frame_load(callback, {targetIds});
  176. }
  177.  
  178. // Removes an event listener previously registered with addEventListener().
  179. function removeEventListener(eventName, listener, options) {
  180. if (listener == null) return false;
  181. if (typeof eventName === 'object' && 'name' in eventName) eventName = eventName.name;
  182. if (typeof eventName !== 'string' || !(eventName in eventHandlers)) return false;
  183. const handlers = eventHandlers[eventName];
  184. if (!handlers.has(listener)) return false;
  185. const listenerOptions = handlers.get(listener);
  186. if (deepEqual(listenerOptions, options)) {
  187. handlers.delete(listener);
  188. if (handlers.size === 0) removeInternalEventListener(eventName);
  189. return true;
  190. }
  191. return false;
  192. }
  193.  
  194. function removeInternalEventListener(eventName) {
  195. if (typeof eventName !== 'string') return false;
  196. if (!(eventName in internalHandlers)) return false;
  197. const {handler, active} = internalHandlers[eventName];
  198. if (handler.persistent || !active) return false;
  199. document.documentElement.removeEventListener(eventName, handler.listener, handler.listenerOptions);
  200. internalHandlers[eventName].active = false;
  201. delete internalHandlers[eventName];
  202. return true;
  203. }
  204.  
  205. // Call event handlers.
  206. async function handleEvent(event, url) {
  207. await Promise.all(getEventHandlers(event, url));
  208. }
  209.  
  210. /**
  211. * Helpers
  212. */
  213.  
  214. function deepEqual(x, y) {
  215. const ok = Object.keys, tx = typeof x, ty = typeof y;
  216. return x && y && tx === 'object' && tx === ty ? (
  217. ok(x).length === ok(y).length &&
  218. ok(x).every(key => deepEqual(x[key], y[key]))
  219. ) : (x === y);
  220. }
  221.  
  222. /**
  223. * Deep freezes an object and all its nested properties.
  224. *
  225. * @template T
  226. * @param {T} o - The object to freeze.
  227. * @return {Readonly<T>} - The frozen object.
  228. */
  229. function deepFreeze(o) {
  230. if (o != null && (typeof o === 'object' || typeof o === 'function'))
  231. Object.values(o).filter(v => !Object.isFrozen(v)).forEach(deepFreeze);
  232. return Object.freeze(o);
  233. }
  234.  
  235. function * getEventHandlers(event, url) {
  236. if (event === undefined || event === null || !(event.type in eventHandlers)) return;
  237. for (const [listener, options] of eventHandlers[event.type])
  238. yield emitHandler(event, url, listener, options);
  239. }
  240.  
  241. function getValidEventName(eventName) {
  242. if (typeof eventName === 'string') return eventName;
  243. if (Array.isArray(eventName) && 'name' in eventName[1]) eventName = eventName[1].name; // e.g., `Object.entries(wkof.turbo.events)[0]`
  244. if (typeof eventName === 'object' && 'name' in eventName) eventName = eventName.name; // e.g., `Object.values(wkof.turbo.events)[0]` or `wkof.turbo.events.click`
  245. if (typeof eventName !== 'string') return null;
  246. return eventName;
  247. }
  248.  
  249. // Yield a promise for each listener
  250. function emitHandler(event, url, listener, options) {
  251. if (!verifyOptions(event, url, options)) return Promise.resolve();
  252. if (options?.once) removeEventListener(event.type, listener, options);
  253. return new Promise(resolve => {
  254. if (!options?.noTimeout) setTimeout(()=> resolve(listener(event,url)), 0);
  255. else resolve(listener(event, url));
  256. });
  257. }
  258.  
  259. /**
  260. * Normalizes the input `strings` into a Set of strings.
  261. *
  262. * @param {(any|any[]|Set<any>|null|undefined)} strings - The input strings to be normalized.
  263. * @return {Set<string>} A Set of strings containing the string values from the input.
  264. */
  265. function normalizeToStringSet(strings) {
  266. const output = new Set();
  267. if (strings === undefined || strings === null) return output;
  268. if (strings instanceof Set) {
  269. for (const str of strings)
  270. if (typeof str === 'string')
  271. output.add(str);
  272. return output;
  273. }
  274. if (!Array.isArray(strings)) strings = [strings];
  275. for (const str of strings) {
  276. if (typeof str === 'string')
  277. output.add(str);
  278. }
  279. return output;
  280. }
  281.  
  282. /**
  283. * Normalizes the input object `input` into an array of RegExp objects.
  284. *
  285. * @param {(any|any[]|null|undefined)} input - The input to be normalized.
  286. * @return {RegExp[]} An array of RegExp objects containing input values coerced into RegExp objects.
  287. */
  288. function normalizeToRegExpArray(input) {
  289. const output = [];
  290. if (input === undefined || input === null) return output;
  291. if (!Array.isArray(input)) input = [input];
  292. for (const url of input) {
  293. if (url instanceof RegExp) output.push(url);
  294. else if (typeof url === 'string') output.push(new RegExp(url.replaceAll(/[.+?^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*')));
  295. }
  296. return output;
  297. }
  298.  
  299. function verifyOptions(event, url, options) {
  300. // Ignore cached pages. See https://discuss.hotwired.dev/t/before-cache-render-event/4928/4
  301. if (options?.nocache && event.target?.hasAttribute('data-turbo-preview')) return false;
  302. const urls = normalizeToRegExpArray(options?.urls);
  303. if (urls.length > 0 && !urls.some(reg => reg.test(url) && !(reg.lastIndex = 0))) return false;
  304. const ids = normalizeToStringSet(options?.targetIds);
  305. return !(ids.size > 0 && (options?.checkDocumentIds && !ids.values().some(id => document.getElementById(id)) || !ids.has(event.target.id)));
  306. }
  307.  
  308. function isNewerThan(otherVersion) {
  309. let v1 = version.split(`.`).map(v => parseInt(v));
  310. let v2 = otherVersion.split(`.`).map(v => parseInt(v));
  311. return v1.reduce((r, v, i) => r ?? (v === v2[i] ? null : (v > (v2[i] || 0))), null) || false;
  312. }
  313.  
  314. /**
  315. * Initialization
  316. */
  317.  
  318. function addTurboEvents() {
  319. const existingTurbo = (window.unsafeWindow || window).wkof.turbo;
  320. const listenersToActivate = [];
  321. if (existingTurbo) {
  322. if (!isNewerThan(existingTurbo.version)) return;
  323. const internal = existingTurbo['_.internal'];
  324. if (internal == null) return;
  325. Object.assign(eventHandlers,internal.eventHandlers);
  326. for (const [eventName, {handler, active}] of Object.entries(internal.internalHandlers)) {
  327. if (active) {
  328. document.documentElement.removeEventListener(eventName, handler.listener, handler.listenerOptions);
  329. listenersToActivate.push(eventName);
  330. }
  331. }
  332. delete wkof.turbo;
  333. }
  334.  
  335. wkof.turbo = publishedInterface;
  336. Object.defineProperty(wkof, "turbo", {writable: false});
  337. for (const key in turboEvents)
  338. addInternalEventListener(turboEvents[key].name, turboEvents[key].handler, turboEvents[key].handler.persistent || listenersToActivate.includes(turboEvents[key].name));
  339. }
  340.  
  341. function startup() {
  342. if (!window.wkof) {
  343. const response = confirm('WaniKani Open Framework Turbo Events requires WaniKani Open Framework.\n Click "OK" to be forwarded to installation instructions.');
  344. if (response) window.location.href = 'https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549';
  345. return;
  346. }
  347. wkof.ready('wkof')
  348. .then(addTurboEvents)
  349. .then(turboEventsReady);
  350. }
  351.  
  352. function turboEventsReady() {
  353. wkof.set_state('wkof.TurboEvents', 'ready');
  354. }
  355.  
  356. startup();
  357.  
  358. })();