$Config

Allows end users to configure scripts.

目前為 2024-06-28 提交的版本,檢視 最新版本

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.cn-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;
			});
		};
	}
}