$Config

Allows end users to configure scripts.

目前为 2024-06-27 提交的版本。查看 最新版本

此脚本不应直接安装,它是一个供其他脚本使用的外部库。如果您需要使用该库,请在脚本元属性加入:// @require https://update.cn-greasyfork.org/scripts/446506/1401503/%24Config.js

  1. // ==UserScript==
  2. // @name $Config
  3. // @author Callum Latham <callumtylerlatham@gmail.com> (https://github.com/esc-ism/tree-frame)
  4. // @exclude *
  5. // @description Allows end users to configure scripts.
  6. // ==/UserScript==
  7.  
  8. /**
  9. * A node's value.
  10. * @typedef {boolean | number | string} NodeValue
  11. */
  12.  
  13. /**
  14. * A child node.
  15. * @typedef {object} ChildNode
  16. * @property {string} [label] The node's purpose.
  17. * @property {boolean | number | string} [value] The node's data.
  18. * @property {Array<NodeValue> | function(NodeValue): boolean | string} [predicate] A data validator.
  19. * @property {"color" | "date" | "datetime-local" | "email" | "month" | "password" | "search" | "tel" | "text" | "time" | "url" | "week"} [input] The desired input type.
  20. */
  21.  
  22. /**
  23. * A parent node.
  24. * @typedef {object} ParentNode
  25. * @property {Array<ChildNode | (ChildNode & ParentNode)>} children The node's children.
  26. * @property {ChildNode | (ChildNode & ParentNode)} [seed] - A node that may be added to children.
  27. * @property {function(Array<ChildNode>): boolean | string} [childPredicate] A child validator.
  28. * @property {function(Array<ChildNode>): boolean | string} [descendantPredicate] A descendant validator.
  29. * @property {number} [poolId] Children may be moved between nodes with poolId values that match their parent's.
  30. */
  31.  
  32. /**
  33. * A style to pass to the config-editor iFrame.
  34. * @typedef {object} InnerStyle
  35. * @property {number} [fontSize] The base font size for the whole frame.
  36. * @property {string} [borderTooltip] The colour of tooltip borders.
  37. * @property {string} [borderModal] The colour of the modal's border.
  38. * @property {string} [headBase] The base colour of the modal's header.
  39. * @property {'Black / White' | 'Invert'} [headContrast] The method of generating a contrast colour for the modal's header.
  40. * @property {string} [headButtonExit] The colour of the modal header's exit button.
  41. * @property {string} [headButtonLabel] The colour of the modal header's exit button.
  42. * @property {string} [headButtonStyle] The colour of the modal header's style button.
  43. * @property {string} [headButtonHide] The colour of the modal header's node-hider button.
  44. * @property {string} [headButtonAlt] The colour of the modal header's alt button.
  45. * @property {Array<string>} [nodeBase] Base colours for nodes, depending on their depth.
  46. * @property {'Black / White' | 'Invert'} [nodeContrast] The method of generating a contrast colour for nodes.
  47. * @property {string} [nodeButtonCreate] The colour of nodes' add-child buttons.
  48. * @property {string} [nodeButtonDuplicate] The colour of nodes' duplicate buttons.
  49. * @property {string} [nodeButtonMove] The colour of nodes' move buttons.
  50. * @property {string} [nodeButtonDisable] The colour of nodes' toggle-active buttons.
  51. * @property {string} [nodeButtonDelete] The colour of nodes' delete buttons.
  52. * @property {string} [validBackground] The colour used to show that a node's value is valid.
  53. * @property {string} [invalidBackground] The colour used to show that a node's value is invalid.
  54. */
  55.  
  56. // eslint-disable-next-line no-unused-vars
  57. class $Config {
  58. /**
  59. * @param {string} KEY_TREE The identifier used to store and retrieve the user's config.
  60. * @param {ParentNode} TREE_DEFAULT_RAW The tree to use as a starting point for the user's config.
  61. * @param {function(Array<ChildNode | (ChildNode & ParentNode)>): *} _getConfig Takes a root node's children and returns the data structure expected by your script.
  62. * @param {string} TITLE The heading to use in the config-editor iFrame.
  63. * @param {InnerStyle} [STYLE_INNER] A custom style to use as the default
  64. * @param {object} [_STYLE_OUTER] CSS to assign to the frame element. e.g. {zIndex: 9999}.
  65. */
  66. constructor(KEY_TREE, TREE_DEFAULT_RAW, _getConfig, TITLE, STYLE_INNER = {}, _STYLE_OUTER = {}) {
  67. // PRIVATE FUNCTIONS
  68. const getStrippedForest = (children) => {
  69. const stripped = [];
  70. for (const child of children) {
  71. if (child.isActive === false) {
  72. continue;
  73. }
  74. const data = {};
  75. if ('value' in child) {
  76. data.value = child.value;
  77. }
  78. if ('label' in child) {
  79. data.label = child.label;
  80. }
  81. if ('children' in child) {
  82. data.children = getStrippedForest(child.children);
  83. }
  84. stripped.push(data);
  85. }
  86. return stripped;
  87. };
  88. const getConfig = ({children}) => _getConfig(getStrippedForest(children));
  89. const getError = (message, error) => {
  90. if (error) {
  91. console.error(error);
  92. }
  93. return new Error(message.includes('\n') ? `[${TITLE}]\n\n${message}` : `[${TITLE}] ${message}`);
  94. };
  95. // PRIVATE CONSTS
  96. const URL = {
  97. SCHEME: 'https',
  98. HOST: 's3.eu-west-2.amazonaws.com',
  99. PATH: 'callumlatham.com/tree-frame-4/index.html',
  100. PARAMS: `?id=${KEY_TREE}`,
  101. };
  102. const KEY_STYLES = 'TREE_FRAME_USER_STYLES';
  103. const STYLE_OUTER = {
  104. position: 'fixed',
  105. height: '100vh',
  106. width: '100vw',
  107. ..._STYLE_OUTER,
  108. };
  109. // CORE PERMISSION CHECKS
  110. if (typeof GM.getValue !== 'function') {
  111. throw getError('Missing GM.getValue permission.');
  112. }
  113. if (typeof GM.setValue !== 'function') {
  114. throw getError('Missing GM.setValue permission.');
  115. }
  116. if (typeof KEY_TREE !== 'string' || KEY_TREE === '') {
  117. throw getError(`'${KEY_TREE}' is not a valid storage key.`);
  118. }
  119. // PRIVATE STATE
  120. let isOpen = false;
  121. // PUBLIC FUNCTIONS
  122. const setConfig = (tree) => {
  123. const config = getConfig(tree);
  124. this.get = () => config;
  125. };
  126. this.ready = (async () => {
  127. // Remove functions from tree to enable postMessage transmission
  128. const [DATA_INIT, PREDICATES] = (() => {
  129. const getNumberedPredicates = (node, predicateCount) => {
  130. const predicates = [];
  131. const replacements = {};
  132. for (const property of ['predicate', 'childPredicate', 'descendantPredicate']) {
  133. switch (typeof node[property]) {
  134. case 'number':
  135. throw getError('numbers aren\'t valid predicates');
  136. case 'function':
  137. replacements[property] = predicateCount++;
  138. predicates.push(node[property]);
  139. }
  140. }
  141. if (Array.isArray(node.children)) {
  142. replacements.children = [];
  143. for (const child of node.children) {
  144. const [replacement, childPredicates] = getNumberedPredicates(child, predicateCount);
  145. predicateCount += childPredicates.length;
  146. predicates.push(...childPredicates);
  147. replacements.children.push(replacement);
  148. }
  149. }
  150. if ('seed' in node) {
  151. const [replacement, seedPredicates] = getNumberedPredicates(node.seed, predicateCount);
  152. predicates.push(...seedPredicates);
  153. replacements.seed = replacement;
  154. }
  155. return [{...node, ...replacements}, predicates];
  156. };
  157. const [TREE_DEFAULT_PROCESSED, PREDICATES] = getNumberedPredicates(TREE_DEFAULT_RAW, 0);
  158. return [
  159. {
  160. defaultTree: TREE_DEFAULT_PROCESSED,
  161. title: TITLE,
  162. defaultStyle: STYLE_INNER,
  163. }, PREDICATES,
  164. ];
  165. })();
  166. // Setup frame
  167. const [targetWindow, frame] = (() => {
  168. const frame = document.createElement('iframe');
  169. frame.src = `${URL.SCHEME}://${URL.HOST}/${URL.PATH}${URL.PARAMS}`;
  170. for (const [property, value] of Object.entries(STYLE_OUTER)) {
  171. frame.style[property] = value;
  172. }
  173. frame.style.display = 'none';
  174. let targetWindow = window;
  175. while (targetWindow !== targetWindow.parent) {
  176. targetWindow = targetWindow.parent;
  177. }
  178. targetWindow.document.body.appendChild(frame);
  179. return [targetWindow, frame];
  180. })();
  181. // Retrieve data & await frame load
  182. const communicate = async (callback = () => false) => {
  183. const getShouldContinue = async ({origin, data}) => {
  184. if (origin === `${URL.SCHEME}://${URL.HOST}` && data.id === KEY_TREE) {
  185. return await callback(data);
  186. }
  187. return true;
  188. };
  189. return await new Promise((resolve, reject) => {
  190. const listener = (message) => getShouldContinue(message)
  191. .then((shouldContinue) => {
  192. if (!shouldContinue) {
  193. resolve(message.data);
  194. targetWindow.removeEventListener('message', listener);
  195. }
  196. })
  197. .catch((error) => {
  198. targetWindow.removeEventListener('message', listener);
  199. reject(getError(error.message, error));
  200. });
  201. targetWindow.addEventListener('message', listener);
  202. });
  203. };
  204. const [userTree, userStyles, {'events': EVENTS, password}] = await Promise.all([
  205. GM.getValue(KEY_TREE),
  206. GM.getValue(KEY_STYLES, []),
  207. communicate(),
  208. ]);
  209. // Listen for post-init communication
  210. const sendMessage = (message) => {
  211. frame.contentWindow.postMessage({...message, id: KEY_TREE}, `${URL.SCHEME}://${URL.HOST}`);
  212. };
  213. const openFrame = (doOpen = true) => new Promise((resolve) => {
  214. isOpen = doOpen;
  215. frame.style.display = doOpen ? (STYLE_OUTER.display ?? 'initial') : 'none';
  216. // Delay script execution until visual update
  217. setTimeout(resolve, 0);
  218. });
  219. const disconnectFrame = () => new Promise((resolve) => {
  220. isOpen = false;
  221. frame.remove();
  222. // Delay script execution until visual update
  223. setTimeout(resolve, 0);
  224. });
  225. /**
  226. * @name $Config#reset
  227. * @description Deletes the user's data.
  228. * @returns {Promise<void>} Resolves upon completing the deletion.
  229. */
  230. this.reset = async () => {
  231. if (isOpen) {
  232. throw getError('Cannot reset while a frame is open.');
  233. }
  234. if (typeof GM.deleteValue !== 'function') {
  235. throw getError('Missing GM.deleteValue permission.');
  236. }
  237. try {
  238. setConfig(TREE_DEFAULT_RAW);
  239. } catch (error) {
  240. throw getError('Unable to parse default config.', error);
  241. }
  242. sendMessage({
  243. password,
  244. event: EVENTS.RESET,
  245. });
  246. await GM.deleteValue(KEY_TREE);
  247. this.ready = Promise.resolve();
  248. };
  249. const sendPredicateResponse = (data) => sendMessage({
  250. password,
  251. event: EVENTS.PREDICATE,
  252. messageId: data.messageId,
  253. predicateResponse: PREDICATES[data.predicateId](
  254. Array.isArray(data.arg) ? getStrippedForest(data.arg) : data.arg,
  255. ),
  256. });
  257. /**
  258. * @name $Config#edit
  259. * @description Allows the user to edit the active config.
  260. * @returns {Promise<void>} Resolves when the user closes the config editor.
  261. */
  262. this.edit = async () => {
  263. if (isOpen) {
  264. throw getError('A config editor is already open.');
  265. }
  266. openFrame();
  267. await communicate((data) => {
  268. switch (data.event) {
  269. case EVENTS.STOP:
  270. openFrame(false);
  271. return false;
  272. case EVENTS.PREDICATE:
  273. sendPredicateResponse(data);
  274. return true;
  275. }
  276. console.warn(getError(`Message observed from tree-frame site with unrecognised 'event' value: ${data.event}`, data));
  277. return true;
  278. });
  279. };
  280. // Pass data
  281. sendMessage({
  282. password,
  283. event: EVENTS.START,
  284. userStyles,
  285. ...(userTree ? {userTree} : {}),
  286. ...DATA_INIT,
  287. });
  288. await communicate(async (data) => {
  289. switch (data.event) {
  290. case EVENTS.PREDICATE:
  291. sendPredicateResponse(data);
  292. return true;
  293. case EVENTS.ERROR:
  294. // Flags that removing userTree won't help
  295. delete this.reset;
  296. await disconnectFrame();
  297. throw getError(
  298. 'Your config is invalid.'
  299. + '\nThis could be due to a script update or your data being corrupted.'
  300. + `\n\nError Message:\n${data.reason.replaceAll(/\n+/g, '\n')}`,
  301. );
  302. case EVENTS.RESET:
  303. throw getError(
  304. 'Your config is invalid.'
  305. + '\nThis could be due to a script update or your data being corrupted.'
  306. + `\n\nError Message:\n${data.reason.replaceAll(/\n+/g, '\n')}`,
  307. );
  308. case EVENTS.START:
  309. setConfig(data.tree);
  310. return false;
  311. }
  312. console.warn(getError(`Message observed from tree-frame site with unrecognised 'event' value: ${data.event}`, data));
  313. return true;
  314. });
  315. })();
  316. }
  317. }