Proposals on variables
Opened this issue ยท 17 comments
I'm in the process of presenting a fork in which variables (named variables, magic variables and global variables (Ask, Clipboard, CurrentDate and ExtensionInput)) are objects instead of strings. In my local repository, I wrote:
const ActionOutput = (name?: string): Attachment => ({
OutputUUID: uuidv4(),
OutputName: name,
Type: 'ActionOutput',
});
const Variable = (name: string): Attachment => ({
VariableName: name,
Type: 'Variable',
});
const Ask: Attachment = { Type: 'Ask' };
const Clipboard: Attachment = { Type: 'Clipboard' };
const CurrentDate: Attachment = { Type: 'CurrentDate' }; // TODO date/time formats
const ExtensionInput: Attachment = { Type: 'ExtensionInput' };
Using lowerCamelCase, an ask
global variable would conflict with the ask()
action function, so I ended up distinguishing them using CapitalCamelCase for variable objects. It could make sense since in my fork ActionOutput
and Variable
acts like JS types/classes:
const {
buildShortcut,
withVariables,
ActionOutput,
Clipboard,
} = require('@joshfarrant/shortcuts-js');
const {
calculate,
comment,
number,
showResult
} = require('@joshfarrant/shortcuts-js/actions');
// We'll use this later to reference the output of a calculation
const calcVar = ActionOutput('My result');
// Define our list of actions
const actions = [
comment({
text: 'Hello, world!',
}),
number({
number: 42,
}),
calculate({
operand: 3,
operation: '/',
}, calcVar),
showResult({
/**
* We can use the result of the calculation in this Shortcuts's input
* by passing the string to the 'withVariables' tag function
*/
text: withVariables`Total is ${calcVar}! ${Clipboard}`,
}),
];
Unfortunately the current lint script accepts variable names only in lowerCamelCase or UPPER_CASE.
How should I name the Ask global variable?
I've also been thinking this out in my head. For the naming of the global variables, remember that the contribution guide suggests the display name from the shortcuts app itself (in English). My approach is quite similar to yours, but with a couple differences as described below:
// Global Variables
const askWhenRun: WFSerialization = {
Value: {
Type: 'Ask',
},
WFSerializationType: 'WFTextTokenAttachment',
};
const clipboard: WFSerialization = {
Value: {
Type: 'Variable',
},
WFSerializationType: 'WFTextTokenAttachment',
};
const currentDate: WFSerialization = {
Value: {
Type: 'CurrentDate',
},
WFSerializationType: 'WFTextTokenAttachment',
};
const shortcutInput: WFSerialization = {
Value: {
Type: 'ExtensionInput',
},
WFSerializationType: 'WFTextTokenAttachment',
};
// Util methods
const /** or makeMagicVariable */ actionOutput = (name?: string): WFSerialization => ({
Value: {
...(name && { OutputName: name }),
OutputUUID: uuidv4(),
Type: 'ActionOutput',
},
WFSerializationType: 'WFTextTokenAttachment',
});
const formatSerialization = (variable: WFSerialization | string): WFSerialization => {
// Named Variable
if (typeof variable === 'string') {
return {
Value: {
Type: 'Variable',
VariableName: name,
},
WFSerializationType: 'WFTextTokenAttachment',
};
}
// Already Serialized
return variable;
};
const withVariables = (
strings: TemplateStringsArray,
...vars: string[]
): WFSerialization => ({
WFSerializationType: 'WFTextTokenString',
Value: {
string: strings.join('\ufffc'), // Join strings with object replacement character
attachmentsByRange: strings.reduce(
(a, _, i, s) => {
// ...
return {
...a,
[`{${lengthSoFar}, 1}`]: formatSerialization(vars[i]).Value,
};
},
{},
),
},
});
Here I pulled the formatSerialization
function from getVariable
as a util function and made adjustments.
What this allows us is that any variable can be passed into withVariables
and it will use just the Value
object as WFTextTokenString
serialized objects, while also allowing some actions to be able to take in the WFSerialization
variable directly as a parameter for actions that support WFTextTokenAttachments
.
Later on we can improve formatSerialization
to be able to handle parameterizing variables (e.g. date formats, or changing the what input type to parse the variable as.
I'm curious what you think of my approach and any questions you have just ask.
For the naming of the global variables, remember that the contribution guide suggests the display name from the shortcuts app itself (in English).
askWhenRun
and shortcutInput
look way better.
... allowing some actions to be able to take in the WFSerialization variable directly as a parameter for actions that support WFTextTokenAttachments.
I see, boolean values in dictionaries currently support WFTextTokenAttachments.
const formatSerialization = (variable: WFSerialization | string): WFSerialization => { // Named Variable if (typeof variable === 'string') { return { Value: { Type: 'Variable', VariableName: name, }, WFSerializationType: 'WFTextTokenAttachment', }; } // Already Serialized return variable; };
If formatSerialization
won't be used anywhere else, could it be renamed to variable(name: string)
to become the equivalent of actionOutput()
/magicVariable()
for named variables? This way users will have to always use those functions to initialize variables and magic variables:
const result = magicVariable()
const myResult = magicVariable('My Result')
const list = variable('List')
In that scenario where we have variable()
, do you think withVariables
should still have the ability to take in strings
(producing the named variable), or enforce that all named variables have to be initialized? I think I like the latter, as it locks things down, making sure the user has one intention. Similarly we can lock down setVariable/addToVariable
to take in WFSerialization
instead of string
, and just have the actions strip off VariableName
for the WFVariableName
.
@joshfarrant What do you think?
... we can lock down setVariable/addToVariable to take in WFSerialization instead of string, ...
That's exactly what I did. It will remove every check/test and it will make it easier to write the shortcut2js converter (#4) later on.
Using WFSerialization as base, the getVariable
action just becomes:
const getVariable = (
{
variable,
}: GetVariableOptions,
): WFWorkflowAction => ({
WFWorkflowActionIdentifier: 'is.workflow.actions.getvariable',
WFWorkflowActionParameters: {
WFVariable: variable,
},
});
I think I like the latter, as it locks things down, making sure the user has one intention.
@Archez I'd tend to agree with you in leaning towards the latter. Having withVariables
take in strings for named variables can end up in somewhat counterintuitive code.
withVariables`Testing ${'Some Variable'}`;
Could very easily be assumed to be equal to
withVariables`Testing Some Variable`;
Which is clearly is not.
Initialising the named variables beforehand, while it will add a bit more boilerplate, will make the code easier to understand, and the user's intentions clearer.
I really like the direction this is going in, definitely a big improvement over my initial implementation!
That is exactly my thought. The boiler plating aligns more with a 'code' structure rather than a 'iOS Shortcut' structure and also paves the way for a more concrete .shortcut -> .js
parser.
Can't wait to see the results @xAlien95!
Fantastic! Me too @xAlien95! ๐งโโ๏ธ
I pushed it to my fork, you can see and try it there.
@joshfarrant, do I have to split /src/utils/variables.ts
in multiple files?
variable()
and actionOutput()
can be considered as util functions so it would make sense to store them in two separate files, but how should the global variables constants be stored in your opinion?
@Archez, should I use WFSerialization
instead of Attachment
as in your approach?
Note: I really don't know why my real name appeared twice on the commit.. It's the first time I'm using Atom to push commits. Any suggestion on how to prevent this behavior is highly appreciated!
If we do not use the WFSerialization
approach, then any action that can take in a WFTextTokenAttachment
(e.g. number), would have to be responsible for ensuring that the proper serialization step is taken. On the other hand, if we take the WFSerialization
approach, then actions do not have to know the difference between a regular variable and a withVariables
serialized object, allowing the actions code to be straight forward. I guess it would be a choice between handling it one (initialized as WFSerialization
) or handle per basis (actions build the serialized object). I'm leaning towards WFSerialization
, but I am open to objections. I can not imagine it being an issue either way, as user made scripts would be unchanged.
For your note: I just use VSCode (its great just like Atom), and haven't had strange issues like that using it's built in git tools.
@Archez, I'm not familiar with TypeScript, but if we use the WFSerialization
approach we may need to always specify that the .Value
property is a SerializationValue
. TypeScript automatically assumes it's a string
although it can have multiple types.
I split ../interfaces/WF/WFSerialization.ts
in two files and I updated withActionOutput.ts
(rebased version of withUUID.ts
) like this:
import SerializationValue from '../interfaces/WF/SerializationValue';
import WFSerialization from '../interfaces/WF/WFSerialization';
import WFWorkflowAction from '../interfaces/WF/WFWorkflowAction';
export const withActionOutput = <OptionsType>(
actionBuilder: (options: OptionsType) => WFWorkflowAction,
) => (
(
options: OptionsType,
output?: WFSerialization,
): WFWorkflowAction => {
const action = actionBuilder(options);
if (output) {
const value = <SerializationValue>output.Value;
action.WFWorkflowActionParameters.UUID = value.OutputUUID;
if (value.OutputName) {
action.WFWorkflowActionParameters.CustomOutputName = value.OutputName;
}
}
return action;
}
);
Is there a better way to achieve it?
if we use the WFSerialization approach we may need to always specify that the .Value property is a SerializationValue. TypeScript automatically assumes it's a string although it can have multiple types.
That's strange, it shouldn't prefer one type over any other, as far as I know.
As I'm sure you've seen, the WFSerialization interface is defined here, and it's Value should expect a SerializationValue.
This is the first TypeScript project I've built from the ground up, so there may well be a better way of defining the WFSerialization
interface. Don't know how much TS experience you have @Archez, but do you have any thoughts on this?
EDIT: I should also mention that I agree with @Archez in leaning towards WFSerialization
over Attachment
, if possible.
do I have to split /src/utils/variables.ts in multiple files?
variable() and actionOutput() can be considered as util functions so it would make sense to store them in two separate files, but how should the global variables constants be stored in your opinion?
I'd say have variable
and actionOutput
in their own files.
I'm wondering whether it might make sense to have another directory, src/variables
, to store everything related to variables. Or maybe we'd keep variable
and actionOutput
as named exports from the main src/index.ts
(and keep them stored in src/utils
), and just have askWhenRun
, clipboard
, etc defined in a single src/variables.ts
file.
That way, users would have something like:
import {
buildShortcut,
withVariables,
variable,
actionOutput,
} from '@joshfarrant/shortcuts-js';
import {
askWhenRun,
clipboard,
} from '@joshfarrant/shortcuts-js/variables';
Thoughts?
I'm wondering whether it might make sense to have another directory,
src/variables
, to store everything related to variables.
Later on, it could be useful to have a src/variables
directory. I'm still doing researches on the Aggrandizements
property and I'm starting to think that a Variable
class with a .with()
method could suit well:
const result = variable('Result');
const actions = [
number({
number: 1112223333,
}),
setVariable({
variable: result,
}),
showResult({
text: withVariables`Number is ${result}, phone number is ${result.with({
type: 'PhoneNumber',
})}!`,
}),
];
... we'd keep variable and actionOutput as named exports from the main
src/index.ts
...
I agree with having both variable
and actionOutput
to be available from '@joshfarrant/shortcuts-js'
.
@joshfarrant, this issue is going off-topic from the original title. Should I rename it to something like "Proposals on variables", or close this one and open a new one?
@xAlien95 .with()
sounds interesting. I've not looked into the Aggrandizements
property at all - is that essentially casting the type to a phone number then (in your example)?
I agree about renaming the issue, please go ahead ๐
I've not looked into the Aggrandizements property at all - is that essentially casting the type to a phone number then (in your example)?
@joshfarrant yes (although a phone number cast does nothing at all, it just returns the number as a string since the number type doesn't have properties).
If you set a Text action with "Today at 9:00 am" and then you insert its output magic variable to be considered as a Date, you'll get the same time string you get typing "Today at 9:00 am" in the Date action. Aggrandizements
can be avoided and replaced by actions, but they are useful if you don't want to break the action flow with actions like Get Dictionary Value: you can just cast a magic variable to a Dictionary and you'll be able to write the Key you want to obtain.
@joshfarrant, step 2 of the variable implementation is live in my fork's variable-class branch.
- Rebased variables on
WFSerialization
- Added
Variable class
with a.with()
method for Aggrandizements - Added first implementation of some
Aggrandizement
types - Added PascalCase variable/class name validation in the linter (#26)
To do:
- Update actions that can handle variables as parameter to have
WFSerialization
as additional parameter type - Update
utils/buildSerialization.ts
to handlewithVariables
tagged template strings - Add TypeDoc documentation for
.with()
method, with some examples
Contributors that have to implement actions that accept variables as parameters have to just add WFSerialization
as additional type for that particular parameter (#27 (comment)), whilst users have to simply insert the variable:
getDeviceDetails({
detail: askWhenRun,
})
Regarding .with()
, its parameters are just the same that Shortcuts provides.
I put the coercion type under the type
parameter:
const myVar = variable('My Variable');
myVar.with({
type: 'Dictionary',
getValueForKey: 'My Key',
});
myVar.with({
type: 'Date',
dateFormat: 'Custom',
customFormat: 'dd MMM at HH:mm',
});
myVar.with({
type: 'Image',
get: 'Album',
});
You can take a look at all the changes here: xAlien95@024d0ba.
I'm open to suggestions and considerations ๐
I'll add a pull request if there won't be issues regarding this implementation.
In step 3 I'll implement all the Aggrandizements, I already listed them all in this gist.
After that work, there shouldn't be other significative modifications to prevent us to start working on the .shortcut -> .js
parser (#4).
@xAlien95 Looks fantastic, great work. Can't wait to see the PR, feel free to submit whenever you're ready.
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.