What is Best Practice to Pass Parameters to a Script in KM?

What is Best Practice to Pass Parameters to a Script in KM?

I actually asked this question last year:

but did not get any responses.

Since then, we have a number of new KM forum members who are more developer-oriented. So, I'm hoping some of you have given this some thought.

I'll use JXA/JavaScript as an example, a starting point, but this would apply to any scripting language.

Here is one very simple approach. I'm not recommending this, just throwing it out as an example.

Before calling the script, set a KM Variable, "ScriptParams" to a CSV list of input parameters:
myKMVar,10,true

The Script

var kme = Application("Keyboard Maestro Engine");
var kmScriptParams = kme.getvariable("ScriptParams")
var kmParamList = kmScriptParams.split(',')

kmParamList
//-->["myKMVar", "10", "true"]

So now, in the script, we have an array of parameter values.

But I hate reinventing the wheel. Surely this is a problem someone has already thought of and solved.

Anyone have a solution, or a better idea?

1 Like

I know this post is long, so I'll keep the important stuff at the top.

In my macro Spotlight Search Prompt, and the other macros that use it, I adopted a different approach, and it works extremely well, except for one outlier situation (which I'll discuss below).

Here's an example:

The format is this.

  • One parameter per line
  • Format is "name: value" (without the quotes, and the space between the colon and the value is optional).
  • If a line is blank, or starts with "--" it is ignored. Leading "--" can be used to "comment out" a line.
  • If a parameter allows multiple values, they are separated by commas, as in jsonAdditionalSearchFields: keyword1, keyword2

This gives me a set of named parameters. Having named parameters allows for optional parameters with ease, and the order of parameters in this list isn't relevant. Also, as mentioned above, it's easy to "comment out" individual lines.

When this is parsed in JavaScript, it becomes something like this:

if (option.title)...

doProcessing(option.dataType);

etc

The one situation this doesn't support is a multi-line parameter. There are ways of handling that, though.

This is very easy to parse in JavaScript, but interestingly enough, it's also easy to parse in KM.

The above example looks for the parameter "customPromptWidth" in the list of parameters, and uses a default value if the parameter doesn't exist. It uses the syntax "^customPromptWidth:", which means "start of a line, immediately followed by "customPromptWidth" and a colon, it effectively ignores leading "--" (or anything else, for that matter).


Details:

I will be happy to provide detailed, documented code to anyone who wants it, and I can write code for anyone who can't quite figure out how to parse the options.

As mentioned, I also have Validation code, if you'd like to see how that works.

For now, here's some examples of parsing the options. If you don't have my complex requirements, the code for parsing the options is as simple as this:

function parseSimpleSpotlightOptions(sspOptions) {
    // start with empty options
    var options = { };

    // split up into multiple lines for easy parsing
    var lines = sspOptions.split(/[\r\n]+/);

    // process each line
    lines.forEach(function(line) {
        // skip blank lines, and lines that start with "--"
        if (!line.trim() || line.indexOf("--") === 0) return;

        // split up into name and value (function is below - it throws errors
        // if the line's format is invalid).
        var nameValue = getNameValue(line, ":", "option");
        var key = nameValue.name;
        var value = nameValue.value;

        // If there's no value, ignore the option. Not strictly necessary.
        if (!value) return;

        // if you don't need to do any special parsing for specific values,
        // i.e. you don't need to process options that support comma-separated
        // values, this is all you need:
        switch (key) {
            case "title":
            case "placeholder":
            case "dataType":
            case "helpMacroUUID":
            case "customButtonSubmitResultButton":
            case "customButtonTriggerMacroUUID":
            case "saveWindowPositionVariableName":
            case "testing":
                options[key] = value;
                break;
            default:
                throw Error("Unknown option: '" + key + "'");
        }
    });
    return options;
}

(And yes, @ComplexPoint, there are more compact ways of writing this. Let's try and remember that the people who can understand the compact ways of writing this don't need me to explain how to do it in the first place, OK? :slight_smile:)

Here's what I actually use in Spotlight Search Prompt, which has some complex requirements:

var lines = sspOptions.split(/[\r\n]+/);
lines.forEach(function(line) {
    if (!line.trim() || line.indexOf("--") === 0) return;

    var nameValue = getNameValue(line, ":", "option");
    var key = nameValue.name;
    var value = nameValue.value;
    if (!value) return;

    switch (key) {
        case "customPromptWidth":
        case "customPromptHeight":
        case "customPickListSize":
            // These are handled in the macro.
            break;
        case "title":
        case "placeholder":
        case "dataType":
        case "helpMacroUUID":
        case "customButtonSubmitResultButton":
        case "customButtonTriggerMacroUUID":
        case "saveWindowPositionVariableName":
        case "testing":
            options[key] = value;
            break;
        case "customButtonText":
            var len = value.length;
            if (len > 2 && value.substring(len-2, len-1) === "/") {
                options.customButtonText = value.substring(0, value.length-2);
                options.customButtonAccessKey = value.substring(value.length-1);
            } else {
                options.customButtonText = value;
            }
            break;
        case "cancelMacroIfCancelClicked":
        case "customButtonEnabledWithoutSelection":
            options[key] = value.search(/^(y|yes|t|true|1)$/i) === 0;
            break;
        case "jsonDisplayField":
            options.displayField = value;
            break;
        case "jsonReturnField":
            options.returnField = value;
            break;
        case "jsonAdditionalSearchFields":
            var additionalFields = [];
            value.split(/[, ]/).forEach(function(field) {
                if (!field) return;
                additionalFields.push(field);
            });
            if (additionalFields.length > 0)
                options.additionalSearchFields = additionalFields;
            break;
        case "jsonStatusLineFields":
            var statusLineFields = [];
            value.split(/[, ]/).forEach(function(field) {
                if (!field) return;
                statusLineFields.push(field);
            });
            if (statusLineFields.length > 0)
                options.statusLineFields = statusLineFields;
            break;
        case "jsonStatusLineTemplate":
            options.statusLineTemplate = value;
            break;
        case "jsonStatusLineFunction":
            options.statusLineFunction = value;
            break;
        default:
            throw Error("Unknown option: '" + key + "'");
    }
});

I also have a separate function that validates the options, making sure required parameters are specified, and parameters that can't be used together aren't, etc. Available on request.


One last thing - please allow me to geek-out a little.

The thing I really enjoyed about this whole technique, is I could use Unit Testing on it to make sure everything worked correctly, especially when I changed something. And it saved me, many times. Here's an example of the output - I can give details anytime anyone needs them:

3 Likes

Looks good – a custom parser clearly prunes noise out of data entry.

Perhaps worth mentioning that for simple things you can, of course, directly use KM variables in JSON format, and then use the built-in JSON.parse() inside JS


Perhaps them main cost of direct JSON entry is simply typing all those double-quotes :slight_smile:

I generally skip that by writing the object literal in simpler JavaScript format, and then copying it to the clipboard as JSON. (All JSON is JS, but not all JS is JSON, which can't for example include quotes, and always needs full double-quoting)

e.g. we can simplify data entry a bit by typing this in JS, and then copying it as JSON for pasting into a KM variable assignment

var strClip = (function () {
    'use strict';
    
    var dctData = {
        title: 'Example 6: Additional complex Search Fields',
        helpMacroUUID: 'D9D39328-95A6-44B8-B740-C24F0A815EF7',
        dataType: 'json',
        jsonDisplayField: 'name',
        jsonResturnField: 'value',
        jsonAdditionalSearchFields: ['keyword1', 'keyword2'],
        jsonStatusLineFields: ['city', 'state'],
        jsonStatusLineTemplate: ['Location: $1', '$2'],
        customPromptWidth: 500
    }

    

    return JSON.stringify(dctData, null, 2);

})();

var a = Application.currentApplication(),
    sa = (a.includeStandardAdditions = true, a); 

sa.setTheClipboardTo(strClip);

strClip
1 Like

PS you can also type a set of simple unquoted key : value pairs,
and copy them as JSON, by using an online YAML -> JSON converter like:

Here’s the problem with using JSON, IMO.

  1. If JSON.parse() fails, you don’t get a good error message. As far as I’m concerned, this is unacceptable. I want to know why something failed.

  2. Even if JSON.parse() succeeds, I still want to validate each each option, to make sure that every object key is a valid parameter name. So, if I’m going to have to iterate the object keys anyway, what did I gain?

  3. I wanted a simplified way of specifying arrays.

To be honest, I had originally started with JSON. But when I started encountering the above things, I realized that JSON was more trouble than it was worth.

Believe me when I say that a lot of thought and effort went into what I finally came up with.

2 Likes

Here’s a more general-purpose parser. You pass it the parameters, an array of required parameters, an array of optional parameters, and it does all the work.

function simpleParameterParser(parameters, requiredParameters, optionalParameters) {
    result = {};
    var lines = parameters.split(/[\r\n]+/).filter(function(line) {
        return (line.trim() && !line.startsWith("--"));
    });

    var validParameters = requiredParameters.concat(optionalParameters);
    var result = {};
    lines.forEach(function(line) {
        var i = line.indexOf(":");
        if (i < 0)
            throw Error("Invalid parameter format: '" + line + "' (mising ':')");
        var name = line.substring(0, i).trim();
        var value = line.substring(i + 1).trim();
        if (!name)
            throw Error("Invalid parameter format: '" + line + "'");

        if (validParameters.indexOf(name) < 0)
            throw Error("Invalid parameter '" + name + "'");
        result[name] = value;
    });

    requiredParameters.forEach(function(p) {
        if (!result[p])
            throw Error("Parameter '" + p + "' is required");
    });

    return result;
}


// ========= Examples / Tests ======================
var input1 =
    "firstName: Dan\n" +
    "lastName: Thomas\n" +
    "--comment";
var result1 = simpleParameterParser(input1, ["firstName", "lastName"], ["middleName"]);
console.log(result1);
console.log("------------------------------");

var input2 =
    "firstName: Dan\n" +
    "lastName: Thomas\n" +
    "middleName: I'm not telling";
var result2 = simpleParameterParser(input2, ["firstName", "lastName"], ["middleName"]);
console.log(result2);
console.log("------------------------------");


try {
    var input3 =
        "name: Dan";
    var result3 = simpleParameterParser(input3, ["firstName", "lastName"], ["middleName"]);
    console.log(result3);
} catch (e) {
    console.log(e.message);
}
console.log("------------------------------");

try {
    var input4 =
        "firstName: Dan";
    var result4 = simpleParameterParser(input4, ["firstName", "lastName"], ["middleName"]);
    console.log(result4);
} catch (e) {
    console.log(e.message);
}

Dan, outstanding! :thumbsup:

I had a feeling you might have developed a much, much better approach.
I like it, and will adopt it.

Cool! As always, suggestions, improvements, etc. welcome.

And let me know if I can help.

1 Like

This should be codified in the Keyboard Maestro docs and, even (stretch goal), be built into a library that can be used by any script.

Also, thinking out loud: Could this be built into a KM Plug-in?