- // ==UserScript==
- // @name $Config
- // @author Callum Latham <callumtylerlatham@gmail.com> (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 = (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: '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((data) => {
- switch (data.event) {
- case EVENTS.STOP:
- 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\nError Message:\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\nError Message:\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;
- });
- })();
- }
- }