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