Allows end users to configure scripts.
Tính đến
Script này sẽ không được không được cài đặt trực tiếp. Nó là một thư viện cho các script khác để bao gồm các chỉ thị meta
// @require https://update.greasyfork.org/scripts/446506/1401643/%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. */ // eslint-disable-next-line no-unused-vars 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 = (reason, error) => { const message = `[${TITLE}]${reason.includes('\n') ? '\n\n' : ' '}${reason}`; if (error) { error.message = message; return error; } return new Error(message); }; // PRIVATE CONSTS const URL = { SCHEME: 'https', HOST: 's3.eu-west-2.amazonaws.com', PATH: 'callumlatham.com/tree-frame-4/index.html', PARAMS: `?id=${KEY_TREE}`, }; 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 = async () => { // 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}${URL.PARAMS}`; 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 = async (callback = () => false) => { const getShouldContinue = async ({origin, data}) => { if (origin === `${URL.SCHEME}://${URL.HOST}` && data.id === KEY_TREE) { return await callback(data); } return true; }; return await new Promise((resolve, reject) => { const listener = (message) => getShouldContinue(message) .then((shouldContinue) => { if (!shouldContinue) { resolve(message.data); targetWindow.removeEventListener('message', listener); } }) .catch((error) => { targetWindow.removeEventListener('message', listener); reject(getError(error.message, error)); }); 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, id: KEY_TREE}, `${URL.SCHEME}://${URL.HOST}`); }; const openFrame = (doOpen = true) => new Promise((resolve) => { isOpen = doOpen; frame.style.display = doOpen ? (STYLE_OUTER.display ?? 'initial') : 'none'; // Delay script execution until visual update setTimeout(resolve, 0); }); const disconnectFrame = () => new Promise((resolve) => { isOpen = false; frame.remove(); // Delay script execution until visual update setTimeout(resolve, 0); }); /** * @name $Config#reset * @description Deletes the user's data. * @returns {Promise<void>} Resolves upon completing the deletion. */ this.reset = async () => { if (isOpen) { throw getError('Cannot reset while a frame is open.'); } if (typeof GM.deleteValue !== 'function') { throw getError('Missing GM.deleteValue permission.'); } try { setConfig(TREE_DEFAULT_RAW); } catch (error) { throw getError('Unable to parse default config.', error); } sendMessage({ password, event: EVENTS.RESET, }); await GM.deleteValue(KEY_TREE); this.ready = Promise.resolve(); }; const sendPredicateResponse = (data) => sendMessage({ password, event: EVENTS.PREDICATE, messageId: data.messageId, predicateResponse: PREDICATES[data.predicateId]( Array.isArray(data.arg) ? getStrippedForest(data.arg) : data.arg, ), }); /** * @name $Config#edit * @description Allows the user to edit the active config. * @returns {Promise<void>} Resolves when the user closes the config editor. */ this.edit = async () => { if (isOpen) { throw getError('A config editor is already open.'); } openFrame(); await communicate(async (data) => { switch (data.event) { case EVENTS.STOP: // Save changes GM.setValue(KEY_TREE, data.tree); GM.setValue(KEY_STYLES, data.styles); setConfig(data.tree); await openFrame(false); return false; case EVENTS.PREDICATE: sendPredicateResponse(data); 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, }); await communicate(async (data) => { switch (data.event) { case EVENTS.PREDICATE: sendPredicateResponse(data); return true; case EVENTS.ERROR: // Flags that removing userTree won't help delete this.reset; await disconnectFrame(); throw getError( 'Your config is invalid.' + '\nThis could be due to a script update or your data being corrupted.' + `\n\nReason:\n${data.reason.replaceAll(/\n+/g, '\n')}`, ); case EVENTS.RESET: throw getError( 'Your config is invalid.' + '\nThis could be due to a script update or your data being corrupted.' + `\n\nReason:\n${data.reason.replaceAll(/\n+/g, '\n')}`, ); case EVENTS.START: setConfig(data.tree); return false; } console.warn(getError(`Message observed from tree-frame site with unrecognised 'event' value: ${data.event}`, data)); return true; }); }; } }