// ==UserScript==
// @name Wanikani Open Framework Turbo Events
// @namespace https://greasyfork.org/en/users/11878
// @description Adds helpful methods for dealing with Turbo Events to WaniKani Open Framework
// @version 2.2.4
// @match https://www.wanikani.com/*
// @match https://preview.wanikani.com/*
// @author Inserio
// @copyright 2024, Brian Shenk
// @license MIT; http://opensource.org/licenses/MIT
// @run-at document-start
// @grant none
// ==/UserScript==
/* global wkof */
/* jshint esversion: 11 */
(function() {
'use strict';
const version = '2.2.4';
const turboPrefix = 'turbo:', listenerOptions = {capture: true, once: false, passive: true, signal: undefined}, persistent = false;
const handleDetailFetchResponseResponseUrl = {listener: async event => await handleEvent(event, event.detail.fetchResponse.response.url), listenerOptions, persistent},
handleDetailFormSubmissionFetchRequestUrlHref = {listener: async event => await handleEvent(event, event.detail.formSubmission.fetchRequest.url.href), listenerOptions, persistent},
handleDetailNewElementBaseURI = {listener: async event => await handleEvent(event, event.detail.newElement.baseURI), listenerOptions, persistent},
handleDetailNewFrameBaseURI = {listener: async event => await handleEvent(event, event.detail.newFrame.baseURI), listenerOptions, persistent},
handleDetailNewStreamUrl = {listener: async event => await handleEvent(event, event.detail.newStream.url), listenerOptions, persistent},
handleDetailRequestUrlHref = {listener: async event => await handleEvent(event, event.detail.request.url.href), listenerOptions, persistent},
handleDetailResponseUrl = {listener: async event => await handleEvent(event, event.detail.response.url), listenerOptions, persistent},
handleDetailUrl = {listener: async event => await handleEvent(event, event.detail.url), listenerOptions, persistent},
handleDetailUrlAndUpdateLoadedPage = {listener: async event => await handleEvent(event, lastUrlLoaded = event.detail.url), listenerOptions, persistent: true},
handleDetailUrlHref = {listener: async event => await handleEvent(event, event.detail.url.href), listenerOptions, persistent},
handleTargetBaseURI = {listener: async event => await handleEvent(event, event.target.baseURI), listenerOptions, persistent},
handleTargetHref = {listener: async event => await handleEvent(event, event.target.href), listenerOptions, persistent};
// https://turbo.hotwired.dev/reference/events
const turboEvents = deepFreeze({
click: {source: 'document', name: `${turboPrefix}click`, handler: handleDetailUrl},
before_visit: {source: 'document', name: `${turboPrefix}before-visit`, handler: handleDetailUrl},
visit: {source: 'document', name: `${turboPrefix}visit`, handler: handleDetailUrl},
before_cache: {source: 'document', name: `${turboPrefix}before-cache`, handler: handleTargetBaseURI},
before_render: {source: 'document', name: `${turboPrefix}before-render`, handler: handleTargetBaseURI},
render: {source: 'document', name: `${turboPrefix}render`, handler: handleTargetBaseURI},
load: {source: 'document', name: `${turboPrefix}load`, handler: handleDetailUrlAndUpdateLoadedPage},
morph: {source: 'pageRefresh', name: `${turboPrefix}morph`, handler: handleDetailNewElementBaseURI},
before_morph_element: {source: 'pageRefresh', name: `${turboPrefix}before-morph-element`, handler: handleTargetBaseURI},
before_morph_attribute: {source: 'pageRefresh', name: `${turboPrefix}before-morph-attribute`, handler: handleDetailNewElementBaseURI},
morph_element: {source: 'pageRefresh', name: `${turboPrefix}morph-element`, handler: handleDetailNewElementBaseURI},
submit_start: {source: 'forms', name: `${turboPrefix}submit-start`, handler: handleDetailFormSubmissionFetchRequestUrlHref},
submit_end: {source: 'forms', name: `${turboPrefix}submit-end`, handler: handleDetailFetchResponseResponseUrl},
before_frame_render: {source: 'frames', name: `${turboPrefix}before-frame-render`, handler: handleDetailNewFrameBaseURI},
frame_render: {source: 'frames', name: `${turboPrefix}frame-render`, handler: handleTargetBaseURI},
frame_load: {source: 'frames', name: `${turboPrefix}frame-load`, handler: handleTargetBaseURI},
frame_missing: {source: 'frames', name: `${turboPrefix}frame-missing`, handler: handleDetailResponseUrl},
before_stream_render: {source: 'streams', name: `${turboPrefix}before-stream-render`, handler: handleDetailNewStreamUrl},
before_fetch_request: {source: 'httpRequests', name: `${turboPrefix}before-fetch-request`, handler: handleDetailUrlHref},
before_fetch_response: {source: 'httpRequests', name: `${turboPrefix}before-fetch-response`, handler: handleDetailFetchResponseResponseUrl},
before_prefetch: {source: 'httpRequests', name: `${turboPrefix}before-prefetch`, handler: handleTargetHref},
fetch_request_error: {source: 'httpRequests', name: `${turboPrefix}fetch-request-error`, handler: handleDetailRequestUrlHref},
});
const turboListeners = Object.freeze({
before_cache: (callback, options) => addEventListener(turboEvents.before_cache.name, callback, options),
before_fetch_request: (callback, options) => addEventListener(turboEvents.before_fetch_request.name, callback, options),
before_fetch_response: (callback, options) => addEventListener(turboEvents.before_fetch_response.name, callback, options),
before_frame_render: (callback, options) => addEventListener(turboEvents.before_frame_render.name, callback, options),
before_morph_attribute: (callback, options) => addEventListener(turboEvents.before_morph_attribute.name, callback, options),
before_morph_element: (callback, options) => addEventListener(turboEvents.before_morph_element.name, callback, options),
before_prefetch: (callback, options) => addEventListener(turboEvents.before_prefetch.name, callback, options),
before_render: (callback, options) => addEventListener(turboEvents.before_render.name, callback, options),
before_stream_render: (callback, options) => addEventListener(turboEvents.before_stream_render.name, callback, options),
before_visit: (callback, options) => addEventListener(turboEvents.before_visit.name, callback, options),
click: (callback, options) => addEventListener(turboEvents.click.name, callback, options),
fetch_request_error: (callback, options) => addEventListener(turboEvents.fetch_request_error.name, callback, options),
frame_load: (callback, options) => addEventListener(turboEvents.frame_load.name, callback, options),
frame_missing: (callback, options) => addEventListener(turboEvents.frame_missing.name, callback, options),
frame_render: (callback, options) => addEventListener(turboEvents.frame_render.name, callback, options),
load: (callback, options) => addEventListener(turboEvents.load.name, callback, options),
morph: (callback, options) => addEventListener(turboEvents.morph.name, callback, options),
morph_element: (callback, options) => addEventListener(turboEvents.morph_element.name, callback, options),
render: (callback, options) => addEventListener(turboEvents.render.name, callback, options),
submit_end: (callback, options) => addEventListener(turboEvents.submit_end.name, callback, options),
submit_start: (callback, options) => addEventListener(turboEvents.submit_start.name, callback, options),
visit: (callback, options) => addEventListener(turboEvents.visit.name, callback, options),
});
const common = Object.defineProperties({},{
locations: {value: Object.defineProperties({}, {
dashboard: {value: /^https:\/\/www\.wanikani\.com(\/dashboard.*)?\/?$/},
items_pages: {value: /^https:\/\/www\.wanikani\.com\/(radicals|kanji|vocabulary)\/.+\/?$/},
lessons: {value: /^https:\/\/www\.wanikani\.com\/subject-lessons\/(start|[\d-]+\/\d+)\/?$/},
lessons_picker: {value: /^https:\/\/www\.wanikani\.com\/subject-lessons\/picker\/?$/},
lessons_quiz: {value: /^https:\/\/www\.wanikani\.com\/subject-lessons\/[\d-]+\/quiz.*\/?$/},
reviews: {value: /^https:\/\/www\.wanikani\.com\/subjects\/review.*\/?$/},
}),
}}), commonListeners = Object.defineProperties({},{
events: {value: (eventList, callback, options) => addMultipleEventListeners(eventList, callback, options)},
urls: {value: (callback, urls) => addTypicalPageListener(callback, urls)},
targetIds: {value: (callback, targetIds) => addTypicalFrameListener(callback, targetIds)},
dashboard: {value: callback => addTypicalPageListener(callback, common.locations.dashboard)},
items_pages: {value: callback => addTypicalPageListener(callback, common.locations.items_pages)},
lessons: {value: callback => addTypicalPageListener(callback, common.locations.lessons)},
lessons_picker: {value: callback => addTypicalPageListener(callback, common.locations.lessons_picker)},
lessons_quiz: {value: callback => addTypicalPageListener(callback, common.locations.lessons_quiz)},
reviews: {value: callback => addTypicalPageListener(callback, common.locations.reviews)},
}), eventMap = Object.defineProperties({}, {
common: {value: commonListeners},
event: {value: turboListeners},
});
const eventHandlers = {}, internalHandlers = {};
const publishedInterface = Object.freeze({
add_event_listener: addEventListener,
remove_event_listener: removeEventListener,
on: eventMap,
events: turboEvents,
common: common,
version,
'_.internal': {internalHandlers, eventHandlers}
});
let lastUrlLoaded = '!';
/**
* Listeners
*/
// Sets up a function that will be called whenever the specified event is delivered to the target.
function addEventListener(eventName, listener, options) {
if (listener === undefined || listener === null) return false;
eventName = getValidEventName(eventName);
if (eventName === null) return false;
if (eventName === 'load') {
if (typeof listener !== 'function' || lastUrlLoaded === '!' || !verifyOptions('load', lastUrlLoaded, Object.assign({checkDocumentIds: true}, options))) return false;
listener('load', lastUrlLoaded);
return true;
}
const eventKey = eventName.slice(turboPrefix.length).replaceAll('-', '_');
if (!(eventKey in turboEvents)) return false;
if (!internalHandlers[eventName]?.active) addInternalEventListener(eventName, turboEvents[eventKey].handler, true);
if (!(eventName in eventHandlers)) eventHandlers[eventName] = new Map();
eventHandlers[eventName].set(listener, options);
return true;
}
function addInternalEventListener(eventName, handler, activate) {
if (typeof eventName !== 'string') return false;
let internalHandler;
if (eventName in internalHandlers && internalHandlers[eventName].handler.listenerOptions === handler.listenerOptions)
internalHandler = internalHandlers[eventName];
else internalHandler = internalHandlers[eventName] = {handler, active: false};
if (activate && !internalHandler.active) {
document.documentElement.addEventListener(eventName, handler.listener, handler.listenerOptions);
internalHandler.active = true;
}
return true;
}
function addMultipleEventListeners(eventList, callback, options) {
if (eventList === turboEvents) eventList = Object.values(eventList);
if (Array.isArray(eventList)) {
return eventList.map(eventName => {
const name = getValidEventName(eventName);
return {name, added: addEventListener(name, callback, options)};
});
} else {
const name = getValidEventName(eventList);
return {name, added: addEventListener(name, callback, options)};
}
}
// Add a typical listener to run for the provided urls.
function addTypicalPageListener(callback, urls) {
return commonListeners.events(['load', turboEvents.load.name], callback, {urls});
}
// Add a typical listener to run for the provided urls.
function addTypicalFrameListener(callback, targetIds) {
return turboListeners.frame_load(callback, {targetIds});
}
// Removes an event listener previously registered with addEventListener().
function removeEventListener(eventName, listener, options) {
if (listener == null) return false;
if (typeof eventName === 'object' && 'name' in eventName) eventName = eventName.name;
if (typeof eventName !== 'string' || !(eventName in eventHandlers)) return false;
const handlers = eventHandlers[eventName];
if (!handlers.has(listener)) return false;
const listenerOptions = handlers.get(listener);
if (deepEqual(listenerOptions, options)) {
handlers.delete(listener);
if (handlers.size === 0) removeInternalEventListener(eventName);
return true;
}
return false;
}
function removeInternalEventListener(eventName) {
if (typeof eventName !== 'string') return false;
if (!(eventName in internalHandlers)) return false;
const {handler, active} = internalHandlers[eventName];
if (handler.persistent || !active) return false;
document.documentElement.removeEventListener(eventName, handler.listener, handler.listenerOptions);
internalHandlers[eventName].active = false;
delete internalHandlers[eventName];
return true;
}
// Call event handlers.
async function handleEvent(event, url) {
await Promise.all(getEventHandlers(event, url));
}
/**
* Helpers
*/
function deepEqual(x, y) {
const ok = Object.keys, tx = typeof x, ty = typeof y;
return x && y && tx === 'object' && tx === ty ? (
ok(x).length === ok(y).length &&
ok(x).every(key => deepEqual(x[key], y[key]))
) : (x === y);
}
/**
* Deep freezes an object and all its nested properties.
*
* @template T
* @param {T} o - The object to freeze.
* @return {Readonly<T>} - The frozen object.
*/
function deepFreeze(o) {
if (o != null && (typeof o === 'object' || typeof o === 'function'))
Object.values(o).filter(v => !Object.isFrozen(v)).forEach(deepFreeze);
return Object.freeze(o);
}
function * getEventHandlers(event, url) {
if (event === undefined || event === null || !(event.type in eventHandlers)) return;
for (const [listener, options] of eventHandlers[event.type])
yield emitHandler(event, url, listener, options);
}
function getValidEventName(eventName) {
if (typeof eventName === 'string') return eventName;
if (Array.isArray(eventName) && 'name' in eventName[1]) eventName = eventName[1].name; // e.g., `Object.entries(wkof.turbo.events)[0]`
if (typeof eventName === 'object' && 'name' in eventName) eventName = eventName.name; // e.g., `Object.values(wkof.turbo.events)[0]` or `wkof.turbo.events.click`
if (typeof eventName !== 'string') return null;
return eventName;
}
// Yield a promise for each listener
function emitHandler(event, url, listener, options) {
if (!verifyOptions(event, url, options)) return Promise.resolve();
if (options?.once) removeEventListener(event.type, listener, options);
return new Promise(resolve => {
if (!options?.noTimeout) setTimeout(()=> resolve(listener(event,url)), 0);
else resolve(listener(event, url));
});
}
/**
* Normalizes the input `strings` into a Set of strings.
*
* @param {(any|any[]|Set<any>|null|undefined)} strings - The input strings to be normalized.
* @return {Set<string>} A Set of strings containing the string values from the input.
*/
function normalizeToStringSet(strings) {
const output = new Set();
if (strings === undefined || strings === null) return output;
if (strings instanceof Set) {
for (const str of strings)
if (typeof str === 'string')
output.add(str);
return output;
}
if (!Array.isArray(strings)) strings = [strings];
for (const str of strings) {
if (typeof str === 'string')
output.add(str);
}
return output;
}
/**
* Normalizes the input object `input` into an array of RegExp objects.
*
* @param {(any|any[]|null|undefined)} input - The input to be normalized.
* @return {RegExp[]} An array of RegExp objects containing input values coerced into RegExp objects.
*/
function normalizeToRegExpArray(input) {
const output = [];
if (input === undefined || input === null) return output;
if (!Array.isArray(input)) input = [input];
for (const url of input) {
if (url instanceof RegExp) output.push(url);
else if (typeof url === 'string') output.push(new RegExp(url.replaceAll(/[.+?^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*')));
}
return output;
}
function verifyOptions(event, url, options) {
// Ignore cached pages. See https://discuss.hotwired.dev/t/before-cache-render-event/4928/4
if (options?.nocache && event.target?.hasAttribute('data-turbo-preview')) return false;
const urls = normalizeToRegExpArray(options?.urls);
if (urls.length > 0 && !urls.some(reg => reg.test(url) && !(reg.lastIndex = 0))) return false;
const ids = normalizeToStringSet(options?.targetIds);
return !(ids.size > 0 && (options?.checkDocumentIds && !ids.values().some(id => document.getElementById(id)) || !ids.has(event.target.id)));
}
function isNewerThan(otherVersion) {
let v1 = version.split(`.`).map(v => parseInt(v));
let v2 = otherVersion.split(`.`).map(v => parseInt(v));
return v1.reduce((r, v, i) => r ?? (v === v2[i] ? null : (v > (v2[i] || 0))), null) || false;
}
/**
* Initialization
*/
function addTurboEvents() {
const existingTurbo = (window.unsafeWindow || window).wkof.turbo;
const listenersToActivate = [];
if (existingTurbo) {
if (!isNewerThan(existingTurbo.version)) return;
const internal = existingTurbo['_.internal'];
if (internal == null) return;
Object.assign(eventHandlers,internal.eventHandlers);
for (const [eventName, {handler, active}] of Object.entries(internal.internalHandlers)) {
if (active) {
document.documentElement.removeEventListener(eventName, handler.listener, handler.listenerOptions);
listenersToActivate.push(eventName);
}
}
delete wkof.turbo;
}
wkof.turbo = publishedInterface;
Object.defineProperty(wkof, "turbo", {writable: false});
for (const key in turboEvents)
addInternalEventListener(turboEvents[key].name, turboEvents[key].handler, turboEvents[key].handler.persistent || listenersToActivate.includes(turboEvents[key].name));
}
function startup() {
if (!window.wkof) {
const response = confirm('WaniKani Open Framework Turbo Events requires WaniKani Open Framework.\n Click "OK" to be forwarded to installation instructions.');
if (response) window.location.href = 'https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549';
return;
}
wkof.ready('wkof')
.then(addTurboEvents)
.then(turboEventsReady);
}
function turboEventsReady() {
wkof.set_state('wkof.TurboEvents', 'ready');
}
startup();
})();