joshfarrant/shortcuts-js

Add a script which can import a .shortcut file and generate JS from it

joshfarrant opened this issue ยท 11 comments

It could be useful to essentially do the reverse of what Shortcuts-js currently does, and generate JavaScript from an imported .shortcut file.

This could be useful to help people get started with the library and start modifying their existing Shortcuts.

We'd have to find a way to add a placeholder (maybe a comment action?) if a specific action isn't yet implemented in Shortcuts-js.

How should the .js file be generated? Should this library/module be able to convert .shortcut to .js without data loss (i.e. having the obtained .js file to build exactly - except for magic variables UUIDs - the same imported .shortcut file)?

Yes, that's what I'm envisaging. As you said; in an ideal world you'd be able to take a .shortcut, convert it to JS, then back to a .shortcut. The two .shortcut files should be completely identical.

As you said; in an ideal world you'd be able to take a .shortcut, convert it to JS, then back to a .shortcut. The two .shortcut files should be completely identical.

Magic variables CustomOutputName has to be handled then. Currently you handle output variables by setting their UUIDs and you place variables in a WFTextTokenString putting those UUIDs as strings in JS template strings (That's smart! Really! I didn't even know of tags in JS).

With @Archez's pull request, output variables and "proper variables" (the ones you define with setVariable) can be placed in JS template strings and a testUUID function is executed to check if a string have to be considered an output variable or a proper variable. This currently doesn't scale to shortcuts "global variables" (Ask, Clipboard, CurrentDate and ExtensionInput) since it wouldn't be possible to distinguish a Clipboard global variable to a proper variable called "Clipboard" (in Shortcut it's allowed to name a proper variable "Clipboard", as it's allowed to name different output variables with the same name).

A solution could be to have JS objects instead of strings and to pass those objects to withVariables tag function, something on this line:

let outputVariable = {
  uuid: 'b74c81a8-192a-463f-a0a6-2d327963714f',
  name: 'Division Result',
  type: 'ActionOutput'
}

let properVariable = {
  name: 'Test Name',
  type: 'Variable'
}

let clipboardGlobalVariable = { type: 'Clipboard' }
let askGlobalVariable = { type: 'Ask' }
let currentDateGlobalVariable = { type: 'CurrentDate' }
let extensionInputGlobalVariable = { type: 'ExtensionInput' }
let calcId

const actions = [
  number({
    number: 42
  }),
  calculate({
    operand: 3,
    operation: '/'
  }, (id) => {
    return calcId = { uuid: id, name: 'Division Result', type: 'ActionOutput' }
  })
]

What is your opinion?

I'm currently looking into defining serialized constants for the global variables. These constants would bypass the string check, and allow them to be used directly.

I agree with @xAlien95 suggestion as far as attempting to set the OutputName for magic variables. However, I believe there needs to be a way that the OutputName is also set on the original action (I need to double check this with a real shortcut if that is the case).

EDIT: Yes, the original action that gets a renamed magic variable has a CustomOutputName WFWorkflowActionParameter parameter

I agree with @xAlien95 suggestion as far as attempting to set the OutputName for magic variables. However, I believe there needs to be a way that the OutputName is also set on the original action (I need to double check this with a real shortcut if that is the case).

My suggestion is to remove the UUID creation in withUUID, rename withUUID to withActionOutput and create an actionOutput help function:

const actionOutput = name => ({ uuid: uuidv4(), name: name, type: 'ActionOutput' })
export const withActionOutput = <OptionsType>(
  actionBuilder: (options: OptionsType) => WFWorkflowAction,
) => (
  (
    options: OptionsType,
    actionOutput?: ActionOutput,
  ): WFWorkflowAction => {
    const action = actionBuilder(options);

    // If we've got an action output
    if (actionOutput) {
      action.WFWorkflowActionParameters.UUID = actionOutput.uuid;
      if (actionOutput.name) action.WFWorkflowActionParameters.CustomOutputName = actionOutput.name;
    }
    return action;
  }
);

This is currently the best approach I can think of, which results in a slightly different way to write the code.

Before you had:

let calcId

const actions = [
  number({
    number: 42
  }),
  calculate({
    operand: 3,
    operation: '/'
  }, (id) => {
    calcId = id
  })
]

While now you have:

let calcId = actionOutput('Division Result')

const actions = [
  number({
    number: 42
  }),
  calculate({
    operand: 3,
    operation: '/'
  }, calcId)
]

In this way the user has to define (and create) the action output before its use in the actionBuilder.

joshfarrant said:

xAlien95 said:

After that work, there shouldn't be other significative modifications to prevent us to start working on the .shortcut -> .js parser.

This is very exciting. Everything else is coming along well, so I might start taking a look into ways to approach that soon.

@joshfarrant, do you want to start working on the parser on your own?

I didn't wrote anything exhaustive, but I have a few features that the parser should have:

  • call it buildScript, in line with the existing buildShortcut
  • place it in /utils, or /utils/script if the functions will be too many
  • define a Script interface to be passed and filled by each function, in which there will be magic variables, variables, actions and metadata

Something on this line:

const buildScript = (
  shortcut: WFWorkflow,
): Script => {
  ...
};
interface Script {
  metadata: {
    glyph?: number;
    color?: number;
  };
  variables: {
    name: string; // the variable name in the built .js file
    variableName: string;
  }[];
  actionOutputs: {
    name: string; // the variable name in the built .js file
    outputName?: string;
  }[];
  actions: Action[];
  // a list to keep track of all the functions to be imported from the npm module
  imports: { [submodule: string]: string[] };
}

This way, a .js file can be built from the Script output and styles could be applied (minified, with tabs of 2 spaces, with tabs of 4 spaces, or even a .ts output file).

I've been making some progress on this over the last few days. Currently, the process looks something like this:

const {
  buildScript,
} = require('../build');

(async () => {
  const script = await buildScript('shortcuts/Playground.shortcut');

  console.log(script);
})();

Outputs:

{
  "imports": {
    "actions": [
      "calculate"
    ]
  },
  "metadata": {
    "glyph": "E96C",
    "color": "GRAY"
  },
  "actions": [
    {
      "name": "calculate",
      "options": {
        "scientificOperation": "x^y",
        "operand": 7
      }
    },
    {
      "name": "calculate",
      "options": {
        "operation": "+",
        "operand": 42
      }
    },
    {
      "name": "calculate",
      "options": {
        "scientificOperation": "โˆ›x",
        "operand": 7
      }
    }
  ]
}

This Script object can then be used to generate code in whatever format the user likes. It's heavily based on @xAlien95's suggestion above, with the small modification that the metadata.glyph and metadata.color properties are both strings with the name of the GLYPH/COLOR, rather than just the raw value from the .shortcut file.

It will require a few modifications to all existing actions:

  • Actions will need their parameters extracting into their own Options interface
  • Actions will need to export an invert function which exactly reverts the main action builder function (export const invert = (WFAction: WFWorkflowAction): Options => { /* ... */ };).
  • Actions will need to export their identifier as a named export (export const identifier = 'is.workflow.actions.math';) which is used to identify the inverter to use when the actions are being parsed from the .shortcut file.

For example, these modifications would convert the the comment action from it's current state into this:

import WFWorkflowAction from '../interfaces/WF/WFWorkflowAction';

interface Options {
  /** The body of the comment */
  text?: string;
}

export const identifier = 'is.workflow.actions.comment';

/**
 * @action Comment
 * @section Actions > Scripting >
 * @icon Text
 *
 * This action lets you explain how part of a shortcut works. When run, this action does nothing.
 *
 * ```js
 * // Create a comment
 * comment({
 *   text: 'A very important comment',
 * });
 * ```
 */
const comment = (
  {
    text = '',
  }: Options,
): WFWorkflowAction => ({
  WFWorkflowActionIdentifier: identifier,
  WFWorkflowActionParameters: {
    WFCommentActionText: text,
  },
});

export const invert = (
  WFAction: WFWorkflowAction,
): Options => ({
  text: WFAction.WFWorkflowActionParameters.WFCommentActionText,
});

export default comment;

That's it, for the time being. The implementation may well change before it's released, but I just wanted to share a bit of info on how it's all looking. I'll publish my branch in it's current state later on today.

@joshfarrant, it's better to place TypeDoc comments on default values:

import WFWorkflowAction from '../interfaces/WF/WFWorkflowAction';

interface Options {
  text?: string;
}

const identifier = 'is.workflow.actions.comment';

/**
 * @action Comment
 * @section Actions > Scripting >
 * @icon Text
 *
 * This action lets you explain how part of a shortcut works. When run, this action does nothing.
 *
 * ```js
 * // Create a comment
 * comment({
 *   text: 'A very important comment',
 * });
 * ```
 */
const comment = (
  {
    /** The body of the comment */
    text = '',
  }: Options,
): WFWorkflowAction => ({
  WFWorkflowActionIdentifier: identifier,
  WFWorkflowActionParameters: {
    WFCommentActionText: text,
  },
});

const invert = (
  WFAction: WFWorkflowAction,
): Options => ({
  text: WFAction.WFWorkflowActionParameters.WFCommentActionText,
});

export {
  comment as default,
  invert,
  identifier,
};

This is the only way to get default values from TypeDoc. Correct value types can be extracted from the Options interface.

๐Ÿ‘Œ

Iโ€™ve pushed to the script-generator branch with my changes so far. As I said, this is far from done and Iโ€™m open to feedback/questions on any decisions.

Sent with GitHawk

This could be an amazing feature! I'd love to see this in action as I think both JavaScript and Apple's editor are amazing tools, each with their own advantages. I think it's a lot easier to code large JSON objects and a lot easier to search + drag and drop to find the exact action you need.

I'd love to be able to convert from shortcut to js and back easily!

Is there any update for merging this branch? I feel it has gone a little stale:

This branch is 16 commits ahead, 689 commits behind master.

I also have some other ideas, maybe I should create new issues for them, let me know all your thoughts:

  1. Live IDE website where you can just code the javascript, using a text editor node package (maybe with intellisense/autocomplete?) and click a button to download the shortcut. Possibly adding the ability to upload shortcuts too, with this "script-generator"

  2. Possibly a desktop app (electron maybe?) where we can edit shortcuts directly on the mac! I'm running the shortcuts app on MacOS Monterey Beta and it's amazing being able to write shortcuts with a keyboard and mouse on a full size screen. I imagine it would be a lot easier to edit the shortcut files directly through the filesystem on MacOS.