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