Wanikani Open Framework Turbo Events

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

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

// ==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();

})();