$Config

Allows end users to configure scripts.

נכון ליום 18-12-2023. ראה הגרסה האחרונה.

אין להתקין סקריפט זה ישירות. זוהי ספריה עבור סקריפטים אחרים // @require https://update.greasyfork.org/scripts/446506/1298241/%24Config.js

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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