$Config

Allows end users to configure scripts.

目前为 2023-12-18 提交的版本。查看 最新版本

此脚本不应直接安装,它是一个供其他脚本使用的外部库。如果您需要使用该库,请在脚本元属性加入:// @require https://update.gf.qytechs.cn/scripts/446506/1298241/%24Config.js

// ==UserScript==
// @name        $Config
// @author      Callum Latham <[email protected]> (https://github.com/esc-ism/tree-frame)
// @exclude     *
// @description Allows end users to configure scripts.
// ==/UserScript==

/**
 * A node's value.
 *
 * @typedef {boolean | number | string} NodeValue
 */

/**
 * A child node.
 *
 * @typedef {Object} ChildNode
 * @property {string} [label] The node's purpose.
 * @property {boolean | number | string} [value] The node's data.
 * @property {Array<NodeValue> | function(NodeValue): boolean | string} [predicate] A data validator.
 * @property {"color" | "date" | "datetime-local" | "email" | "month" | "password" | "search" | "tel" | "text" | "time" | "url" | "week"} [input] The desired input type.
 */

/**
 * A parent node.
 *
 * @typedef {Object} ParentNode
 * @property {Array<ChildNode | (ChildNode & ParentNode)>} children The node's children.
 * @property {ChildNode | (ChildNode & ParentNode)} [seed] - A node that may be added to children.
 * @property {function(Array<ChildNode>): boolean | string} [childPredicate] A child validator.
 * @property {function(Array<ChildNode>): boolean | string} [descendantPredicate] A descendant validator.
 * @property {number} [poolId] Children may be moved between nodes with poolId values that match their parent's.
 */

/**
 * A style to pass to the config-editor iFrame.
 *
 * @typedef {Object} InnerStyle
 * @property {number} [fontSize] The base font size for the whole frame.
 * @property {string} [borderTooltip] The colour of tooltip borders.
 * @property {string} [borderModal] The colour of the modal's border.
 * @property {string} [headBase] The base colour of the modal's header.
 * @property {'Black / White', 'Invert'} [headContrast] The method of generating a contrast colour for the modal's header.
 * @property {string} [headButtonExit] The colour of the modal header's exit button.
 * @property {string} [headButtonLabel] The colour of the modal header's exit button.
 * @property {string} [headButtonStyle] The colour of the modal header's style button.
 * @property {string} [headButtonHide] The colour of the modal header's node-hider button.
 * @property {string} [headButtonAlt] The colour of the modal header's alt button.
 * @property {Array<string>} [nodeBase] Base colours for nodes, depending on their depth.
 * @property {'Black / White', 'Invert'} [nodeContrast] The method of generating a contrast colour for nodes.
 * @property {string} [nodeButtonCreate] The colour of nodes' add-child buttons.
 * @property {string} [nodeButtonDuplicate] The colour of nodes' duplicate buttons.
 * @property {string} [nodeButtonMove] The colour of nodes' move buttons.
 * @property {string} [nodeButtonDisable] The colour of nodes' toggle-active buttons.
 * @property {string} [nodeButtonDelete] The colour of nodes' delete buttons.
 * @property {string} [validBackground] The colour used to show that a node's value is valid.
 * @property {string} [invalidBackground] The colour used to show that a node's value is invalid.
 */

class $Config {
    /**
     * @param {string} KEY_TREE The identifier used to store and retrieve the user's config.
     * @param {ParentNode} TREE_DEFAULT_RAW The tree to use as a starting point for the user's config.
     * @param {function(Array<ChildNode | (ChildNode & ParentNode)>): *} _getConfig Takes a root node's children and returns the data structure expected by your script.
     * @param {string} TITLE The heading to use in the config-editor iFrame.
     * @param {InnerStyle} [STYLE_INNER] A custom style to use as the default
     * @param {Object} [_STYLE_OUTER] CSS to assign to the frame element. e.g. {zIndex: 9999}.
     */
    constructor(KEY_TREE, TREE_DEFAULT_RAW, _getConfig, TITLE, STYLE_INNER = {}, _STYLE_OUTER = {}) {
        // PRIVATE FUNCTIONS

        const getStrippedForest = (children) => {
            const stripped = [];

            for (const child of children) {
                if (child.isActive === false) {
                    continue;
                }

                const data = {};

                if ('value' in child) {
                    data.value = child.value;
                }

                if ('label' in child) {
                    data.label = child.label;
                }

                if ('children' in child) {
                    data.children = getStrippedForest(child.children);
                }

                stripped.push(data);
            }

            return stripped;
        };

        const getConfig = ({children}) => _getConfig(getStrippedForest(children));

        const getError = (message, error) => {
            if (error) {
                console.error(error);
            }

            return new Error(message.includes('\n') ? `[${TITLE}]\n\n${message}` : `[${TITLE}] ${message}`);
        };

        // PRIVATE CONSTS

        const URL = {
            'SCHEME': 'https',
            'HOST': 'callumlatham.com',
            'PATH': 'tree-frame-2',
        };

        const KEY_STYLES = 'TREE_FRAME_USER_STYLES';

        const STYLE_OUTER = {
            'position': 'fixed',
            'height': '100vh',
            'width': '100vw',
            ..._STYLE_OUTER,
        };

        // CORE PERMISSION CHECKS

        if (typeof GM.getValue !== 'function') {
            throw getError('Missing GM.getValue permission.');
        }

        if (typeof GM.setValue !== 'function') {
            throw getError('Missing GM.setValue permission.');
        }

        if (typeof KEY_TREE !== 'string' || KEY_TREE === '') {
            throw getError(`'${KEY_TREE}' is not a valid storage key.`);
        }

        // PRIVATE STATE

        let isOpen = false;

        // PUBLIC FUNCTIONS

        const setConfig = (tree) => {
            const config = getConfig(tree);

            this.get = () => config;
        };

        this.ready = new Promise(async (resolve, reject) => {
            // Remove functions from tree to enable postMessage transmission

            const [DATA_INIT, PREDICATES] = (() => {
                const getNumberedPredicates = (node, predicateCount) => {
                    const predicates = [];
                    const replacements = {};

                    for (const property of ['predicate', 'childPredicate', 'descendantPredicate']) {
                        switch (typeof node[property]) {
                            case 'number':
                                throw getError('numbers aren\'t valid predicates');

                            case 'function':
                                replacements[property] = predicateCount++;

                                predicates.push(node[property]);
                        }
                    }

                    if (Array.isArray(node.children)) {
                        replacements.children = [];

                        for (const child of node.children) {
                            const [replacement, childPredicates] = getNumberedPredicates(child, predicateCount);

                            predicateCount += childPredicates.length;

                            predicates.push(...childPredicates);

                            replacements.children.push(replacement);
                        }
                    }

                    if ('seed' in node) {
                        const [replacement, seedPredicates] = getNumberedPredicates(node.seed, predicateCount);

                        predicates.push(...seedPredicates);

                        replacements.seed = replacement;
                    }

                    return [{...node, ...replacements}, predicates];
                };

                const [TREE_DEFAULT_PROCESSED, PREDICATES] = getNumberedPredicates(TREE_DEFAULT_RAW, 0);

                return [{
                    'defaultTree': TREE_DEFAULT_PROCESSED,
                    'title': TITLE,
                    'defaultStyle': STYLE_INNER,
                }, PREDICATES];
            })();

            // Setup frame

            const [targetWindow, frame] = (() => {
                const frame = document.createElement('iframe');

                frame.src = `${URL.SCHEME}://${URL.HOST}/${URL.PATH}`;

                for (const [property, value] of Object.entries(STYLE_OUTER)) {
                    frame.style[property] = value;
                }

                frame.style.display = 'none';

                let targetWindow = window;

                while (targetWindow !== targetWindow.parent) {
                    targetWindow = targetWindow.parent;
                }

                targetWindow.document.body.appendChild(frame);

                return [targetWindow, frame];
            })();

            // Retrieve data & await frame load

            const communicate = (callback = () => false) => new Promise((resolve) => {
                const listener = async ({origin, data}) => {
                    if (origin === `${URL.SCHEME}://${URL.HOST}`) {
                        let shouldContinue;

                        try {
                            shouldContinue = await callback(data);
                        } catch (e) {
                            debugger;
                        } finally {
                            if (!shouldContinue) {
                                targetWindow.removeEventListener('message', listener);

                                resolve(data);
                            }
                        }
                    }
                };

                targetWindow.addEventListener('message', listener);
            });

            const [userTree, userStyles, {'events': EVENTS, password}] = await Promise.all([
                GM.getValue(KEY_TREE),
                GM.getValue(KEY_STYLES, []),
                communicate(),
            ]);

            // Listen for post-init communication

            const sendMessage = (message) => {
                frame.contentWindow.postMessage(message, `${URL.SCHEME}://${URL.HOST}`);
            };

            const openFrame = (doOpen = true) => new Promise((resolve) => {
                isOpen = doOpen;

                frame.style.display = doOpen ? (STYLE_OUTER.display ?? 'initial') : 'none';

                // Delay upcoming script functionality until the frame updates
                setTimeout(resolve, 0);
            });

            const disconnectFrame = () => new Promise((resolve) => {
                isOpen = false;

                frame.remove();

                // Delay upcoming script functionality until the frame updates
                setTimeout(resolve, 0);
            });

            /**
             * @name $Config#reset
             * @description Deletes the user's data.
             * @return {Promise<void>} Resolves upon completing the deletion.
             */
            this.reset = () => new Promise((resolve, reject) => {
                if (isOpen) {
                    reject(getError('Cannot reset while a frame is open.'));
                } else if (typeof GM.deleteValue !== 'function') {
                    reject(getError('Missing GM.deleteValue permission.'));
                } else {
                    try {
                        setConfig(TREE_DEFAULT_RAW);
                    } catch (error) {
                        reject(getError('Unable to parse default config.', error));

                        return;
                    }

                    sendMessage({
                        password,
                        'event': EVENTS.RESET,
                    });

                    GM.deleteValue(KEY_TREE)
                        .then(resolve)
                        .catch(reject);
                }
            });

            /**
             * @name $Config#edit
             * @description Allows the user to edit the active config.
             * @return {Promise<void>} Resolves when the user closes the config editor.
             */
            this.edit = () => new Promise(async (resolve, reject) => {
                if (isOpen) {
                    reject(getError('A config editor is already open.'));
                } else {
                    openFrame();

                    communicate(async (data) => {
                        if (data.event !== EVENTS.STOP) {
                            return true;
                        }

                        resolve();

                        return false;
                    });
                }
            });

            communicate((data) => {
                switch (data.event) {
                    case EVENTS.PREDICATE:
                        sendMessage({
                            password,
                            'event': EVENTS.PREDICATE,
                            'messageId': data.messageId,
                            'predicateResponse': PREDICATES[data.predicateId](
                                Array.isArray(data.arg) ? getStrippedForest(data.arg) : data.arg,
                            ),
                        });

                        return true;

                    case EVENTS.ERROR:
                        // Flags that removing userTree won't help
                        delete this.reset;

                        disconnectFrame().then(() => {
                            reject(getError(
                                'Your config is invalid.' +
                                '\nThis could be due to a script update or your data being corrupted.' +
                                `\n\nError Message:\n${data.reason.replaceAll(/\n+/g, '\n')}`,
                            ));
                        });

                        return false;

                    case EVENTS.RESET:
                        reject(getError(
                            'Your config is invalid.' +
                            '\nThis could be due to a script update or your data being corrupted.' +
                            `\n\nError Message:\n${data.reason.replaceAll(/\n+/g, '\n')}`,
                        ));

                        return true;

                    case EVENTS.START:
                        setConfig(data.tree);

                        resolve();

                        return true;

                    case EVENTS.STOP:
                        openFrame(false);

                        // Save changes
                        GM.setValue(KEY_TREE, data.tree);
                        GM.setValue(KEY_STYLES, data.styles);

                        setConfig(data.tree);

                        return true;
                }

                console.warn(getError(`Message observed from tree-frame site with unrecognised 'event' value: ${data.event}`, data));

                return true;
            });

            // Pass data

            sendMessage({
                password,
                'event': EVENTS.START,
                userStyles,
                ...(userTree ? {userTree} : {}),
                ...DATA_INIT,
            });
        });
    }
}

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址