$Config

Allows end users to configure scripts.

Version au 18/12/2023. Voir la dernière version.

Ce script ne doit pas être installé directement. C'est une librairie destinée à être incluse dans d'autres scripts avec la méta-directive // @require https://update.greasyfork.org/scripts/446506/1298241/%24Config.js

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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