$Config

Allows end users to configure scripts.

目前為 2022-08-13 提交的版本,檢視 最新版本

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.gf.qytechs.cn/scripts/446506/1081059/%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 STATE

        let config;
        let isOpen = false;

        // PRIVATE FUNCTIONS

        const getConfig = (() => {
            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;
            };

            return ({children}) => _getConfig(getStrippedForest(children));
        })();

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

            return new Error(`[${TITLE}] ${message}`);
        };

        // PRIVATE CONSTS

        const CONFIG_DEFAULT = (() => {
            try {
                return getConfig(TREE_DEFAULT_RAW);
            } catch (error) {
                throw getError('Unable to parse default config.', error);
            }
        })();

        // PUBLIC FUNCTIONS

        /**
         * @name $Config#init
         * @description Instantiates the active config.
         * @return {Promise<void>} Resolves upon retrieving user data.
         */
        this.init = () => new Promise(async (resolve, reject) => {
            if (typeof GM.getValue !== 'function') {
                reject(getError('The GM.getValue permission is required to retrieve data.'));
            } else {
                const userTree = await GM.getValue(KEY_TREE_USER);

                if (userTree) {
                    try {
                        config = await getConfig(userTree);
                    } catch (error) {
                        reject(getError('\n\nUnable to parse config.\nTry opening and closing the config editor to update your data\'s structure.', error));
                    }
                } else {
                    config = CONFIG_DEFAULT;
                }

                resolve();
            }
        });

        /**
         * @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.'));
            }

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

            config = CONFIG_DEFAULT;

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

        /**
         * @name $Config#get
         * @return {*} The active config.
         */
        this.get = () => config;

        /**
         * @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 = (() => {
            const KEY_STYLES = 'TREE_FRAME_USER_STYLES';

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

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

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

            return new Promise(async (resolve, reject) => {
                if (isOpen) {
                    reject(getError('A config editor is already open.'));
                } else if (typeof GM.getValue !== 'function') {
                    reject(getError('Missing GM.getValue permission.'));
                } else if (typeof GM.setValue !== 'function') {
                    reject(getError('Missing GM.setValue permission.'));
                } else if (typeof KEY_TREE_USER !== 'string' || KEY_TREE_USER === '') {
                    reject(getError(`'${KEY_TREE_USER}' is not a valid storage key.`));
                } else {
                    isOpen = true;

                    // Setup frame

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

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

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

                        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);
                                } 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_USER),
                        GM.getValue(KEY_STYLES, []),
                        communicate()
                    ]);

                    // Listen for post-init communication

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

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

                        frame.remove();

                        // Wait for the DOM to update
                        setTimeout(resolve, 1);
                    });

                    communicate(async (data) => {
                        switch (data.event) {
                            case EVENTS.ERROR:
                                await closeFrame();

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

                                return false;

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

                                return true;

                            case EVENTS.STOP:
                                await closeFrame();

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

                                config = getConfig(data.tree);

                                resolve();

                                return false;

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

                                return false;
                        }
                    });

                    // Pass data

                    sendMessage({
                        password,
                        userStyles,
                        ...(userTree ? {userTree} : {}),
                        ...DATA_INIT
                    });
                }
            });
        });
    };
}

QingJ © 2025

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