Meta tool – check for basic errors in a KM plugin .plist file

The heart of a Third Party Plugin for Keyboard Maestro is a .plist file which typically points to a script and defines an action-editing dialog.

(The list of required or recognised keys, and the range and type of values expected for each, is detailed at:

https://www.keyboardmaestro.com/documentation/7/pluginactions.html

Here is a tool which just checks any KM plugin .plist which is selected in the Finder, and reports on any basic glitches like:

  • Required keys that are missing
  • Values that are out of range or unrecognised
  • etc

Test KM plugin plist file (selected in Finder).kmmacros (33.5 KB)

Source of the JavaScript for Automation action in the macro:

(function testKMPluginPlist(strPath) {
    'use strict';

    // fileExists :: String -> Bool
    function fileExists(strPath) {
        var fm = $.NSFileManager.defaultManager,
            error = $();
        fm.attributesOfItemAtPathError(
            ObjC.unwrap($(strPath)
                .stringByStandardizingPath),
            error
        );
        return error.code === undefined;
    }


    function isPlugin(dctPlugin, dctModel) {
        // TOP LEVEL FIELDS

        var lstTests = [{
            issue: 'Missing keys',
            test: function () {
                var lstMissing = Object.keys(dctModel)
                    .filter(function (k) {
                        return !dctModel[k].optional;
                    })
                    .filter(function (k) {
                        return lstFound.indexOf(k) === -1;
                    });

                return lstMissing.length ? lstMissing :
                    undefined;
            }
        }, {
            issue: 'Unrecognised keys',
            test: function () {
                var lstUnknown = lstFound
                    .filter(function (k) {
                        return lstFields.indexOf(k) === -1;
                    });

                return lstUnknown.length ? lstUnknown :
                    undefined;
            }
        }, {
            issue: 'Results string ill-formed',
            test: function () {
                var lstOptions = dctModel.Results.options,
                    strResults = dctPlugin.Results || undefined,
                    lstIllFormed = strResults ? (
                        strResults.trim()
                        .split('|')
                        .filter(function (s) {
                            return lstOptions.indexOf(s) === -1;
                        })
                    ) : [];

                return lstIllFormed.length ? lstIllFormed :
                    undefined;

            }
        }, {
            issue: 'Parameters',
            test: function () {
                if (blnParam) {
                    // for each param:
                    var rgxParamName = /^[A-Za-z][\w ]*/,

                        lstIssues = lstParam.reduce(
                            function (a, dctParam) {
                                // all needed fields ?
                                // no popup without menu ?
                                // no unknown fields ?
                                var lstParamFieldsFound = Object
                                    .keys(
                                        dctParam
                                    ),
                                    strType = dctParam.Type,
                                    strMenu = dctParam.Menu,
                                    strLabel = dctParam.Label,


                                    dctParamIssues = {
                                        'parameter': strLabel ||
                                            '<PARAMETER LABEL MISSING>',
                                        'invalidName': rgxParamName
                                            .test(strLabel) ?
                                            undefined : strLabel,
                                        'missing': function () {
                                            var
                                                lstMissing =
                                                lstParamNeeded
                                                .filter(
                                                    function (
                                                        k
                                                    ) {
                                                        return lstParamFieldsFound
                                                            .indexOf(
                                                                k
                                                            ) ===
                                                            -
                                                            1;
                                                    });
                                            return lstMissing
                                                .length ?
                                                lstMissing :
                                                undefined;
                                        }(),
                                        'unrecognized': function () {
                                            var lstOdd =
                                                lstParamFieldsFound
                                                .filter(
                                                    function (
                                                        k
                                                    ) {
                                                        return lstModelParamFields
                                                            .indexOf(
                                                                k
                                                            ) ===
                                                            -
                                                            1;
                                                    }
                                                );
                                            return lstOdd.length ?
                                                lstOdd :
                                                undefined;
                                        }(),
                                        'unknownType': lstParamTypes
                                            .indexOf(strType) !==
                                            -1 ? (undefined) : strType,
                                        'popupWithNoMenu': (
                                                (
                                                    strType ===
                                                    'PopupMenu'
                                                ) && !
                                                strMenu) ||
                                            undefined,
                                        'menuWrongType': strType ===
                                            'PopupMenu' ? (
                                                typeof strMenu !==
                                                'string'
                                            ) ||
                                            strMenu.indexOf(
                                                '|'
                                            ) === -1 : undefined
                                    },

                                    lstParamIssues = Object.keys(
                                        dctParamIssues
                                    )
                                    .filter(function (k) {
                                        // Defined result, other than from name string
                                        return dctParamIssues[
                                            k] && (k !==
                                            'parameter');
                                    });

                                return lstParamIssues.length ?
                                    a.concat(dctParamIssues) : a;

                            }, []);

                    return lstIssues.length ? lstIssues :
                        undefined;

                } else return undefined;
            }
        }]



        // PARAMETERS ?

        // Needed fields found all found ?
        var lstFields = Object.keys(dctModel),
            lstFound = Object.keys(dctPlugin),

            lstParam = dctPlugin.Parameters || [],
            blnParam = lstParam.length,
            dctModelParamPossible = dctModel.Parameters.possible,
            lstModelParamFields = blnParam ? (
                Object.keys(dctModelParamPossible)
            ) : undefined,
            lstParamNeeded = blnParam ? (
                lstModelParamFields.filter(function (k) {
                    return !dctModelParamPossible[k].optional;
                })
            ) : undefined,
            lstParamTypes = dctModel.Parameters.possible.Type.options,

            lstResults = lstTests
            .reduce(function (a, dctTest) {
                var result = {
                    issue: dctTest.issue,
                    keyOrValue: dctTest.test()
                };

                return result.keyOrValue ? a.concat(result) : a;
            }, []);

        return lstResults.length ? lstResults : true;
    }

    // Required -> 1
    // Optional -> -1

    var dctModel = {
        Name: {
            optional: false,
            type: 'string'
        },
        Script: {
            optional: false,
            type: 'string'
        },
        Icon: {
            optional: true,
            type: 'string'
        },
        Title: {
            optional: true,
            type: 'string'
        },
        Timeout: {
            optional: true,
            type: 'real'
        },
        Author: {
            optional: true,
            type: 'string'
        },
        URL: {
            optional: true,
            type: 'string'
        },
        Help: {
            optional: true,
            type: 'string',
        },
        Results: {
            optional: false, // in practice – though not in help file
            type: 'string',
            options: ['None', 'Window', 'Briefly', 'Typing', 'Pasting',
                'Variable', 'Clipboard'
            ]

        },
        Parameters: {
            optional: true,
            type: 'array',
            possible: {
                Label: {
                    optional: false,
                    type: 'string'
                },
                Type: {
                    optional: false,
                    options: ['String', 'TokenString', 'Calculation',
                        'Text', 'TokenText', 'Checkbox', 'PopupMenu', 'Hidden'
                    ]
                },
                Default: {
                    optional: true
                },
                Menu: {
                    optional: true
                }
            }
        }
    };


    if (strPath && fileExists(strPath)) {
        var result = isPlugin(
            ObjC.deepUnwrap(
                $.NSDictionary.dictionaryWithContentsOfFile(
                    strPath
                )
            ),
            dctModel
        );

        return result === true ? true : JSON.stringify(
            result,
            null, 2
        );
    }

})(Application('Keyboard Maestro Engine')
    .getvariable('plistPath'));

2 Likes