setupCommands

Library that creates regular, toggle, and radio menu commands for userscript managers

This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greasyfork.org/scripts/498119/1399005/setupCommands.js

// ==UserScript==
// @name        setupCommands
// @license     MIT
// @namespace   rtonne
// @match       *://*/*
// @version     2.1
// @author      Rtonne
// @description Library that creates regular, toggle, and radio menu commands for userscript managers
// @grant       GM.registerMenuCommand
// @grant       GM.unregisterMenuCommand
// @grant       GM.getValue
// @grant       GM.setValue
// ==/UserScript==

/**
 * Can be the only function of this library used externally.
 * @param {_Command[]} command_list
 */
async function setupCommands(command_list) {
  for (const command of command_list) {
    await _runCommandCheckFunctions(command);
  }
  for (const command of command_list) {
    await _registerCommand(command_list, command);
  }
}

/**
 * @typedef {_ButtonCommand | _ToggleCommand | _RadioCommandGroup} _Command
 */

/**
 * @typedef _ButtonCommand
 * @type {Object}
 * @property {"button"} type A string declaring what type of menu command this is.
 * @property {string} text The text displayed.
 * @property {() => void} clickFunction A function to be run when clicking the command.
 * @property {string} id The id of the command. Required so that if in place replacement is not supported it can be removed.
 * @property {string} [tooltip] The tooltip shown while the cursor hovers the command.
 * @property {boolean} [auto_close] If the userscript manager popup closes when the command is clicked. Its "false" by default.
 * @property {string} [access_key] A key shortcut for the command.
 */

/**
 * @typedef _ToggleCommand
 * @type {Object}
 * @property {"toggle"} type A string declaring what type of menu command this is.
 * @property {string} id The id of the toggle and the key for the value.
 * @property {string} text The text displayed.
 * @property {boolean} [default_value] The default value and toggle state. Its "false" and off by default.
 * @property {string} [tooltip] The tooltip shown while the cursor hovers the command.
 * @property {boolean} [auto_close] If the userscript manager popup closes when the toggle is clicked. Its "false" by default.
 * @property {string} [access_key] A key shortcut for the command.
 * @property {() => void} [uncheckedFunction] A function to be run when this command is unchecked. This will run once on startup if command is unchecked.
 * @property {() => void} [checkedFunction] A function to be run when this command is checked. This will run once on startup if command is checked
 */

/**
 * @typedef _RadioCommandGroup
 * @type {Object}
 * @property {"radio"} type
 * @property {string} id The key for the value.
 * @property {*} [default_value] The default value and which radio is checked by default. If not set or value does not correspond to a radio, no radio will be checked.
 * @property {_RadioCommand[]} radios
 *
 * @typedef _RadioCommand
 * @type {Object}
 * @property {string} text The text displayed.
 * @property {*} value The value that is set to the group's id when clicked.
 * @property {string} id The id of the command. Required so that if in place replacement is not supported it can be removed.
 * @property {string} [tooltip] The tooltip shown while the cursor hovers the command.
 * @property {boolean} [auto_close] If the userscript manager popup closes when the command is clicked. Its "false" by default.
 * @property {string} [access_key] A key shortcut for the command.
 * @property {() => void} [uncheckedFunction] A function to be run when another command in the group is checked. This will run once on startup if command is unchecked.
 * @property {() => void} [checkedFunction] A function to be run when this command is checked. This will run once on startup if command is checked
 */

// To check if in place command replacement is supported
// https://violentmonkey.github.io/api/gm/#gm_registermenucommand
const _can_replace_in_place =
  "test" === GM.registerMenuCommand("test", () => {}, { id: "test" });
GM.unregisterMenuCommand("test");

/**
 * @param {_Command[]} command_list The list of all commands (may be used to replace old commands).
 * @param {_Command} command
 */
async function _registerCommand(command_list, command) {
  if (command.type === "radio") {
    const checked_radio_value = await GM.getValue(
      command.id,
      command.default_value
    );
    for (const radio of command.radios) {
      if (radio.value === checked_radio_value) {
        const text_prefix = "🞊 ";
        GM.registerMenuCommand(text_prefix + radio.text, () => {}, {
          id: radio.id,
          title: radio.tooltip,
          accessKey: radio.access_key,
          autoClose: radio.auto_close !== undefined && radio.auto_close,
        });
      } else {
        const text_prefix = "🞅 ";
        GM.registerMenuCommand(
          text_prefix + radio.text,
          () => _radioCommand(command_list, command, radio.value),
          {
            id: radio.id,
            title: radio.tooltip,
            accessKey: radio.access_key,
            autoClose: radio.auto_close !== undefined && radio.auto_close,
          }
        );
      }
    }
  } else if (command.type === "toggle") {
    let text_prefix;
    if (await GM.getValue(command.id, command.default_value)) {
      text_prefix = "🞕 ";
    } else {
      text_prefix = "🞎 ";
    }
    GM.registerMenuCommand(
      text_prefix + command.text,
      () => _toggleCommand(command_list, command),
      {
        id: command.id,
        title: command.tooltip,
        accessKey: command.access_key,
        autoClose: command.auto_close !== undefined && command.auto_close,
      }
    );
  } else if (command.type === "button") {
    GM.registerMenuCommand(command.text, command.clickFunction, {
      id: command.id,
      title: command.tooltip,
      accessKey: command.access_key,
      autoClose: command.auto_close !== undefined && command.auto_close,
    });
  }
}

/**
 * The callback to be added to the GM.registerCommand of RadioCommand.
 * @param {_Command[]} command_list The list of all commands (may be used to replace old commands).
 * @param {_RadioCommandGroup} command The group of the command being checked.
 * @param {string} value The value of the RadioCommand being checked.
 */
async function _radioCommand(command_list, command, value) {
  await GM.setValue(command.id, value);
  _runCommandCheckFunctions(command);
  if (_can_replace_in_place) {
    await _registerCommand(command_list, command);
  } else {
    // If we can't replace commands, we need to remove them all, then re-add them
    _unregisterCommands(command_list);
    for (const command of command_list) {
      await _registerCommand(command_list, command);
    }
  }
}

/**
 * The callback to be added to the GM.registerCommand of ToggleCommand
 * @param {_Command[]} command_list The list of all commands (may be used to replace old commands).
 * @param {_ToggleCommand} command The command being toggled.
 */
async function _toggleCommand(command_list, command) {
  await GM.setValue(
    command.id,
    !(await GM.getValue(command.id, command.default_value))
  );
  _runCommandCheckFunctions(command);
  if (_can_replace_in_place) {
    await _registerCommand(command_list, command);
  } else {
    // If we can't replace commands, we need to remove them all, then re-add them
    _unregisterCommands(command_list);
    for (const command of command_list) {
      await _registerCommand(command_list, command);
    }
  }
}

/**
 * @param {_Command[]} command_list
 */
function _unregisterCommands(command_list) {
  for (const command of command_list) {
    if (command.type === "radio") {
      for (const radio of command.radios) {
        GM.unregisterMenuCommand(radio.id);
      }
      continue;
    }
    GM.unregisterMenuCommand(command.id);
  }
}

/**
 * Runs the required uncheckedFunction() or checkedFunction() of the command.
 * @param {_Command} command
 */
async function _runCommandCheckFunctions(command) {
  if (command.type === "toggle") {
    if (await GM.getValue(command.id, command.default_value)) {
      if (command.checkedFunction) {
        command.checkedFunction();
      }
    } else {
      if (command.uncheckedFunction) {
        command.uncheckedFunction();
      }
    }
  } else if (command.type === "radio") {
    const value = await GM.getValue(command.id, command.default_value);
    for (const radio of command.radios) {
      if (value === radio.value) {
        if (radio.checkedFunction) {
          radio.checkedFunction();
        }
      } else {
        if (radio.uncheckedFunction) {
          radio.uncheckedFunction();
        }
      }
    }
  }
}