Tampermonkey Config

Simple Tampermonkey script config library

Tento skript by neměl být instalován přímo. Jedná se o knihovnu, kterou by měly jiné skripty využívat pomocí meta příkazu // @require https://update.greasyfork.org/scripts/470224/1460555/Tampermonkey%20Config.js

// ==UserScript==
// @name         Tampermonkey Config
// @name:zh-CN   Tampermonkey 配置
// @license      gpl-3.0
// @namespace    http://tampermonkey.net/
// @version      1.1.2
// @description  Simple Tampermonkey script config library
// @description:zh-CN  简易的 Tampermonkey 脚本配置库
// @author       PRO
// @match        *
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_addValueChangeListener
// ==/UserScript==

class GM_config extends EventTarget {
    /**
     * The version of the GM_config library
     */
    static get version() {
        return "1.1.1";
    }
    /**
     * Built-in processors for user input
     * @type {Object<string, Function>}
     */
    static #builtinProcessors = {
        same: (v) => v,
        not: (v) => !v,
        int: (s) => {
            const value = parseInt(s);
            if (isNaN(value)) throw `Invalid value: ${s}, expected integer!`;
            return value;
        },
        int_range: (s, minStr, maxStr) => {
            const value = parseInt(s);
            if (isNaN(value)) throw `Invalid value: ${s}, expected integer!`;
            const min = (minStr === "") ? -Infinity : parseInt(minStr);
            const max = (maxStr === "") ? +Infinity : parseInt(maxStr);
            if (min !== NaN && value < min) throw `Invalid value: ${s}, expected integer >= ${min}!`;
            if (max !== NaN && value > max) throw `Invalid value: ${s}, expected integer <= ${max}!`;
            return value;
        },
        float: (s) => {
            const value = parseFloat(s);
            if (isNaN(value)) throw `Invalid value: ${s}, expected float!`;
            return value;
        },
        float_range: (s, minStr, maxStr) => {
            const value = parseFloat(s);
            if (isNaN(value)) throw `Invalid value: ${s}, expected float!`;
            const min = (minStr === "") ? -Infinity : parseFloat(minStr);
            const max = (maxStr === "") ? +Infinity : parseFloat(maxStr);
            if (min !== NaN && value < min) throw `Invalid value: ${s}, expected float >= ${min}!`;
            if (max !== NaN && value > max) throw `Invalid value: ${s}, expected float <= ${max}!`;
            return value;
        },
    };
    /**
     * The proxied config object, to be initialized in the constructor
     */
    proxy = {};
    /**
     * Whether to show debug information
     * @type {boolean}
     */
    debug = false;
    /**
     * The config description object, to be initialized in the constructor
     */
    #desc = {};
    /**
     * The built-in input functions
     * @type {Object<string, Function>}
     */
    #builtinInputs = {
        prompt: (prop, orig) => {
            const s = window.prompt(`🤔 New value for ${this.#getProp(prop).name}:`, orig);
            return s === null ? orig : s;
        },
        current: (prop, orig) => orig,
        action: (prop, orig) => {
            this.#dispatch(false, { prop, before: orig, after: orig, remote: false });
            return orig;
        },
        folder: (prop, orig) => {
            const last = GM_config.#dottedToList(prop).pop();
            this.#down(last);
            this.#dispatch(false, { prop, before: orig, after: orig, remote: false });
            return orig;
        },
    };
    /**
     * The built-in types
     */
    #builtinTypes = {
        str: { // String
            value: "",
            input: "prompt",
            processor: "same",
            formatter: "normal",
        },
        bool: { // Boolean
            value: false,
            input: "current",
            processor: "not",
            formatter: "boolean",
        },
        int: { // Integer
            value: 0,
            input: "prompt",
            processor: "int",
            formatter: "normal",
        },
        float: { // Float
            value: 0.0,
            input: "prompt",
            processor: "float",
            formatter: "normal",
        },
        action: { // Action
            value: null,
            input: "action",
            processor: "same",
            formatter: "name_only",
            autoClose: true,
        },
        folder: { // Folder
            value: null,
            items: {},
            input: "folder",
            processor: "same",
            formatter: "folder",
            autoClose: false,
        },
    };
    /**
     * Built-in formatters for user input
     * @type {Object<string, Function>}
     */
    #builtinFormatters = {
        normal: (name, value) => `${name}: ${value}`,
        boolean: (name, value) => `${name}: ${value ? "✔" : "✘"}`,
        name_only: (name, value) => name,
        folder: (name, value) => `${this.#folderDisplay.prefix}${name}${this.#folderDisplay.suffix}`,
    };
    /**
     * A mapping for the registered menu items, from property to menu id
     */
    #registered = {};
    /**
     * Controls the display of the folder
     * @type { {prefix: string, suffix: string, parent: string} }
     */
    #folderDisplay = {
        prefix: "",
        suffix: " >",
        parentText: "< Back",
        parentTitle: "Return to parent folder",
    };
    /**
     * The current path we're at
     * @type {string[]}
     */
    #currentPath = [];
    /**
     * Cache for current config description
     * @type {Object|null}
     */
    #currentDescCache = null;
    /**
     * Get the config description at the current path
     * @type {Object}
     */
    get #currentDesc() {
        if (this.#currentDescCache) return this.#currentDescCache;
        let desc = this.#desc;
        for (const path of this.#currentPath) {
            desc = desc[path].items;
        }
        this.#currentDescCache = desc;
        return desc;
    }
    /**
     * The constructor of the GM_config class
     * @param {Object} desc The config description object
     * @param {Object} [options] Optional settings
     * @param {boolean} [options.immediate=true] Whether to register menu items immediately
     * @param {boolean} [options.debug=false] Whether to show debug information
     */
    constructor(desc, options) { // Register menu items based on given config description
        super();
        // Handle value change events
        /**
         * Handle value change events
         * @param {string} prop The dotted property name
         * @param {any} before The value before the change
         * @param {any} after The value after the change
         * @param {boolean} remote Whether the change is remote
         */
        function onValueChange(prop, before, after, remote) {
            const defaultValue = this.#getProp(prop).value;
            // If `before` or `after` is `undefined`, replace it with default value
            if (before === undefined) before = defaultValue;
            if (after === undefined) after = defaultValue;
            this.#dispatch(true, { prop, before, after, remote });
        }
        // Complete desc & setup value change listeners
        function initDesc(desc, path = [], parentDefault = {}) {
            // Calc true default value for current level
            const $default = Object.assign({}, parentDefault, desc["$default"] ?? {});
            delete desc.$default;
            for (const key in desc) {
                const fullPath = [...path, key];
                desc[key] = Object.assign({}, $default, this.#builtinTypes[desc[key].type] ?? {}, desc[key]);
                if (desc[key].type === "folder") {
                    initDesc.call(this, desc[key].items, fullPath, $default);
                } else {
                    GM_addValueChangeListener(GM_config.#listToDotted(fullPath), onValueChange.bind(this));
                }
            }
        }
        this.#desc = desc;
        initDesc.call(this, this.#desc, [], {
            input: "prompt",
            processor: "same",
            formatter: "normal"
        });
        // Set options
        this.debug = options?.debug ?? this.debug;
        Object.assign(this.#folderDisplay, options?.folderDisplay ?? {});
        // Proxied config
        const proxyCache = {};
        /**
         * Handlers for the proxied config object
         * @param {string} basePath The base path
         */
        const handlers = (basePath) => {
            return {
                has: (target, prop) => {
                    const normalized = GM_config.#normalizeProp(`${basePath}.${prop}`);
                    return this.#getProp(normalized) !== undefined;
                },
                get: (target, prop) => {
                    const normalized = GM_config.#normalizeProp(`${basePath}.${prop}`);
                    const desc = this.#getProp(normalized);
                    if (desc === undefined) return undefined;
                    if (desc.type === "folder") {
                        if (!proxyCache[normalized]) {
                            proxyCache[normalized] = new Proxy({}, handlers(normalized));
                        }
                        return proxyCache[normalized];
                    } else {
                        return this.get(normalized);
                    }
                },
                set: (target, prop, value) => {
                    return this.set(`${basePath}.${prop}`, value);
                },
                ownKeys: (target) => {
                    return this.list(basePath);
                },
                getOwnPropertyDescriptor: (target, prop) => {
                    return { enumerable: true, configurable: true };
                }
            }
        }
        this.proxy = new Proxy({}, handlers(""));
        // Register menu items
        if (window === window.top) {
            if (options?.immediate ?? true) {
                this.#register();
            } else {
                // Register menu items after user clicks "Show configuration"
                const id = GM_registerMenuCommand("Show configuration", () => {
                    this.#register();
                }, {
                    autoClose: false,
                    title: "Show configuration options for this script"
                });
                this.#log(`+ Registered menu command: prop="Show configuration", id=${id}`);
                this.#registered[null] = id;
            }
            this.addEventListener("set", (e) => { // Auto update menu items
                if (e.detail.before !== e.detail.after) {
                    this.#log(`🔧 "${e.detail.prop}" changed from ${e.detail.before} to ${e.detail.after}, remote: ${e.detail.remote}`);
                    const id = this.#registered[e.detail.prop];
                    if (id !== undefined) {
                        this.#registerItem(e.detail.prop);
                    } else {
                        this.#log(`+ Skipped updating menu since it's not registered: prop="${e.detail.prop}"`);
                    }
                }
            });
            this.addEventListener("get", (e) => {
                this.#log(`🔍 "${e.detail.prop}" requested, value is ${e.detail.after}`);
            });
        }
    }
    /**
     * If given a function, calls it with following arguments; otherwise, returns the given value
     * @param {Function|any} value The value or function to be called
     * @param  {...any} args The arguments to be passed to the function
     */
    static #call(value, ...args) {
        return typeof value === "function" ? value(...args) : value;
    }
    /**
     * Convert a dotted string to a list
     * @param {string} dotted The dotted string
     * @returns {string[]} The list
     */
    static #dottedToList(dotted) {
        return dotted.split(".").filter(s => s);
    }
    /**
     * Convert a list to a dotted string
     * @param {string[]} list The list
     * @returns {string} The dotted string
     */
    static #listToDotted(list) {
        return list.join(".");
    }
    /**
     * Normalize a property name
     * @param {string} prop The property name
     * @returns {string} The normalized property name
     */
    static #normalizeProp(prop) {
        return GM_config.#listToDotted(GM_config.#dottedToList(prop));
    }
    /**
     * Get the value of a property
     * @param {string} prop The dotted property name
     * @returns {any} The value of the property
     */
    get(prop) {
        const normalized = GM_config.#normalizeProp(prop);
        // Return stored value, else default value
        const value = this.#get(normalized);
        // Dispatch get event
        this.#dispatch(false, {
            prop: normalized,
            before: value,
            after: value,
            remote: false
        });
        return value;
    }
    /**
     * Set the value of a property
     * @param {string} prop The dotted property name
     * @param {any} value The value to be set
     * @returns {boolean} Whether the value is set successfully
     */
    set(prop, value) {
        const normalized = GM_config.#normalizeProp(prop);
        // Store value
        const desc = this.#getProp(normalized);
        if (desc === undefined) return false; // Property not found
        const defaultValue = desc.value;
        if (value === defaultValue && typeof GM_deleteValue === "function") {
            GM_deleteValue(normalized); // Delete stored value if it's the same as default value
            this.#log(`🗑️ "${normalized}" deleted`);
        } else {
            GM_setValue(normalized, value);
        }
        // Dispatch set event (will be handled by value change listeners)
        return true;
    }
    /**
     * List all properties at the given path
     * @param {string|null|undefined} prop The dotted property name of a folder, or nullish for root
     */
    list(prop) {
        const normalized = GM_config.#normalizeProp(prop ?? "");
        if (normalized) {
            return Object.keys(this.#getProp(normalized)?.items ?? {});
        } else {
            return Object.keys(this.#desc);
        }
    }
    /**
     * Get the description of a property
     * @param {string|string[]} path The path to the property, either a dotted string or a list
     * @returns {Object|undefined} The description of the property, or `undefined` if not found
     */
    #getProp(path) {
        if (typeof path === "string") {
            path = GM_config.#dottedToList(path);
        }
        let desc = this.#desc;
        for (const key of path.slice(0, -1)) {
            desc = desc?.[key]?.items;
        }
        return desc ? desc[path[path.length - 1]] : undefined;
    }
    /**
     * Get the value of a property (only for internal use; won't trigger events)
     * @param {string} prop The dotted property name
     * @returns {any} The value of the property, `undefined` if not found
     */
    #get(prop) {
        return GM_getValue(prop, this.#getProp(prop)?.value);
    }
    /**
     * Log a message if debug is enabled
     * @param  {...any} args The message to log
     */
    #log(...args) {
        if (this.debug) {
            console.log("[GM_config]", ...args);
        }
    }
    /**
     * Dispatches the event
     * @param {string} isSet Whether the event is a set event (`true` for set, `false` for get)
     * @param {Object} detail The detail object
     * @param {string} detail.prop The property name
     * @param {any} detail.before The value before the operation
     * @param {any} detail.after The value after the operation
     * @param {boolean} detail.remote Whether the operation is remote (always `false` for `get`)
     * @returns {boolean} Always `true`
     */
    #dispatch(isSet, detail) {
        const event = new CustomEvent(isSet ? "set" : "get", {
            detail: detail
        });
        return this.dispatchEvent(event);
    }
    /**
     * Go to the parent folder
     * @returns {void}
     */
    #up() {
        this.#currentPath.pop();
        this.#log(`⬆️ Went up to ${GM_config.#listToDotted(this.#currentPath) || "#root"}`);
        this.#register();
    }
    /**
     * Go to a subfolder
     * @param {string} name The name of the subfolder
     */
    #down(name) {
        this.#currentPath.push(name);
        this.#log(`⬇️ Went down to ${GM_config.#listToDotted(this.#currentPath)}`);
        this.#register();
    }
    /**
     * Register menu items at the current path
     */
    #register() {
        this.#currentDescCache = null; // Clear cache
        // Unregister old menu items
        for (const prop in this.#registered) {
            const id = this.#registered[prop];
            GM_unregisterMenuCommand(id);
            delete this.#registered[prop];
            this.#log(`- Unregistered menu command: prop="${prop}", id=${id}`);
        }
        // Register parent menu item (if not at root)
        if (this.#currentPath.length) {
            const id = GM_registerMenuCommand(this.#folderDisplay.parentText, () => {
                this.#up();
            }, {
                autoClose: false,
                title: this.#folderDisplay.parentTitle
            });
            this.#registered[null] = id;
            this.#log(`+ Registered menu command: prop=null, id=${id}`);
        }
        // Register new menu items
        for (const prop in this.#currentDesc) {
            const fullProp = GM_config.#listToDotted([...this.#currentPath, prop]);
            this.#registered[fullProp] = this.#registerItem(fullProp);
        }
    }
    /**
     * (Re-)register a single menu item, return its menu id
     * @param {string} prop The dotted property name
     */
    #registerItem(prop) {
        const { name, input, processor, formatter, accessKey, autoClose, title } = this.#getProp(prop);
        const orig = this.#get(prop);
        const inputFunc = typeof input === "function" ? input : this.#builtinInputs[input];
        const formatterFunc = typeof formatter === "function" ? formatter : this.#builtinFormatters[formatter];
        const option = {
            accessKey: GM_config.#call(accessKey, prop, name, orig),
            autoClose: GM_config.#call(autoClose, prop, name, orig),
            title: GM_config.#call(title, prop, name, orig),
            id: this.#registered[prop],
        };
        const id = GM_registerMenuCommand(formatterFunc(name, orig), () => {
            let value;
            try {
                value = inputFunc(prop, orig);
                if (typeof processor === "function") { // Process user input
                    value = processor(value);
                } else if (typeof processor === "string") {
                    const parts = processor.split("-");
                    const processorFunc = GM_config.#builtinProcessors[parts[0]];
                    if (processorFunc !== undefined) // Process user input
                        value = processorFunc(value, ...parts.slice(1));
                    else // Unknown processor
                        throw `Unknown processor: ${processor}`;
                } else {
                    throw `Unknown processor format: ${typeof processor}`;
                }
            } catch (error) {
                alert("⚠️ " + error);
                return;
            }
            if (value !== orig) {
                this.set(prop, value);
            }
        }, option);
        this.#log(`+ Registered menu command: prop="${prop}", id=${id}, option=`, option);
        return id;
    }
}