$Config

Allows end users to configure scripts.

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

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.cn-greasyfork.org/scripts/446506/1081060/%24Config.js

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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