Redesigning the behaviour of a Keyboard Maestro action at run-time


#1

For some tasks (one collection translated to another), a Map expression can seem a simpler more and natural fit than a For statement.

See, for example:

We don’t have a Map action or option in Keyboard Maestro at the moment, but it may occur to promethean readers of Mary Shelley that we could try assembling a map action by cannibalising a For Each action at run-time. :construction_worker:

Here is the first result of an ongoing ‘don’t necessarily try this at home’ experiment:

(Note: Uses ES6 JavaScript, which collectors of antiquarian (tho not pre-Yosemite) versions of macOS could recompile to ES5 here: https://closure-compiler.appspot.com/home )

Map (window -> (name, position)) over all windows in active application.kmmacros (31.0 KB)

–>


#2

For reference, the JS source, to be run in a KM Execute JavaScript for Automation action, as above.

If you want to test the code outside a running macro, you should obtain the UUID of the target macro with KM Editor > Edit > Copy As > Copy UUID, and paste it in the place indicated at line 236 of this source code.

(When the code is running inside a macro, the correct UUID is obtained automatically)

(() => {
    "use strict";

    // Run-time translation of a Keyboard Maestro FOR EACH statement-action
    // to a MAP expression-action.
    // (returning the value of a function applied across a collection)

    // GENERIC FUNCTIONS -----------------------------------------------------

    // bindMay (>>=) :: Maybe a -> (a -> Maybe b) -> Maybe b
    const bindMay = (mb, mf) =>
        mb.nothing ? mb : mf(mb.just);

    // concatMap :: (a -> [b]) -> [a] -> [b]
    const concatMap = (f, xs) =>
        xs.length > 0 ? [].concat.apply([], xs.map(f)) : [];

    // foldl :: (a -> b -> a) -> a -> [b] -> a
    const foldl = (f, a, xs) => xs.reduce(f, a);

    // isPrefixOf takes two strings and returns
    // true iff the first is a prefix of the second.
    // isPrefixOf :: String -> String -> Bool
    const isPrefixOf = (xs, ys) =>
        ys.startsWith(xs);

    // just :: a -> Just a
    const just = x => ({
        nothing: false,
        just: x
    });

    // log :: a -> IO ()
    const log = (...args) =>
        console.log(
            args
            .map(x => JSON.stringify(x, null, 2))
            .join(' -> ')
        );

    // nothing :: () -> Nothing
    const nothing = (optionalMsg) => ({
        nothing: true,
        msg: optionalMsg
    });

    // readFile :: FilePath -> IO String
    const readFile = strPath => {
        var error = $(),
            str = ObjC.unwrap(
                $.NSString.stringWithContentsOfFileEncodingError(
                    $(strPath)
                    .stringByStandardizingPath,
                    $.NSUTF8StringEncoding,
                    error
                )
            );
        return typeof error.code !== 'string' ? (
            str
        ) : 'Could not read ' + strPath;
    };

    // show :: Int -> a -> Indented String
    // show :: a -> String
    const show = (...x) =>
        JSON.stringify.apply(
            null, x.length > 1 ? [x[1], null, x[0]] : x
        );

    // takeBaseName :: FilePath -> String
    const takeBaseName = strPath =>
        strPath !== '' ? (
            strPath[strPath.length - 1] !== '/' ? (
                strPath.split('/')
                .slice(-1)[0].split('.')[0]
            ) : ''
        ) : '';

    // takeExtension :: FilePath -> String
    const takeExtension = strPath => {
        const
            xs = strPath.split('.'),
            lng = xs.length;
        return lng > 1 ? (
            '.' + xs[lng - 1]
        ) : '';
    };

    // File name template -> temporary path
    // (Random digit sequence inserted between template base and extension)
    // tempFilePath :: String -> IO FilePath
    const tempFilePath = template =>
        ObjC.unwrap($.NSTemporaryDirectory()) +
        takeBaseName(template) + Math.random()
        .toString()
        .substring(3) + takeExtension(template)

    // KEYBOARD MAESTRO ------------------------------------------------------

    // macroForUUIDMay :: String -> String -> Maybe Dict
    const macroForUUIDMay = strUUID => {
        const
            js = ObjC.unwrap,
            ms = concatMap(
                group => {
                    const mb = foldl(
                        (m, x) => m.nothing ? (
                            strUUID === x.UID ? just(x) : m
                        ) : m,
                        nothing('Not found'),
                        (group.Macros || [])
                    );
                    return mb.nothing ? [] : [mb.just];
                }, ObjC.deepUnwrap(
                    $.NSDictionary.dictionaryWithContentsOfFile(
                        js(js($.NSFileManager.defaultManager
                            .URLsForDirectoryInDomains(
                                14, //$.NSApplicationSupportDirectory,
                                1 //$.NSUserDomainMask
                            ))[0].path) +
                        '/Keyboard Maestro/Keyboard Maestro Macros.plist'
                    )
                )
                .MacroGroups
            );
        return ms.length ? (
            just(ms[0])
        ) : nothing('No match found for ' + strUUID);
    };

    // jsoDoScript :: Object (Dict | Array) -> IO ()
    const jsoDoScript = jso => {
        const strPath = tempFilePath('tmp.plist');
        return (
            Application('Keyboard Maestro Engine')
            .doScript((
                $(Array.isArray(jso) ? jso : [jso])
                .writeToFileAtomically(
                    $(strPath)
                    .stringByStandardizingPath,
                    true
                ),
                readFile(strPath)
            )),
            true
        );
    };

    // Testing whether this code is executing inside a KM macro,
    // and if so, supporting instance-sensitive variable resolutions,
    // for example for Local or Instance variables.

    // kmInstanceMay :: -> Maybe Keyboard Maestro Instance
    const kmInstanceMay = () => {
        const oInstance = ObjC.unwrap(
            $.NSProcessInfo.processInfo.environment
            .objectForKey('KMINSTANCE')
        );
        return Boolean(oInstance) ? (
            just(oInstance)
        ) : nothing('This code is not running in a KM Macro');
    };

    // FOR -> MAP ------------------------------------------------------------

    // groupMay :: Dict kmMacro -> Maybe Dict kmAction
    const groupMay = dct =>
        foldl(
            (m, dct) => m.nothing ? (
                dct.MacroActionType === 'Group' &&
                isPrefixOf('Group as MAP', dct.ActionName) ? (
                    just(dct)
                ) : m
            ) : m,
            nothing('Group not found'),
            dct.Actions
        );

    // forEachMay :: Dict kmMacro -> Maybe Dict kmAction
    const forEachMay = dct =>
        foldl(
            (m, dct) => m.nothing ? (
                dct.MacroActionType === 'For' && !dct.IsActive &&
                isPrefixOf('MAP', dct.ActionName) ? (
                    just(dct)
                ) : m
            ) : m,
            nothing('For Each template not found'),
            dct.Actions
        );

    // MAP behaviour derived from inActive FOR EACH action template

    // mapActionMay :: kmMacro Dict -> kmMacro Dict
    const mapActionMay = dctFor => {
        const dctMap = dctFor.Actions[0];
        return just(Object.assign(
            dctFor, {
                Actions: [
                    Object.assign(dctMap, {
                        'Variable': 'Local_ACCUMULATOR',
                        'Text': '%Variable%Local_ACCUMULATOR%' +
                            dctMap.Text + '\n',
                        'ResultAs': dctMap.Variable
                    })
                ],
                IsActive: true
            }
        ));
    };

    // mapEvalnMay :: km Action -> Maybe String
    const mapEvalnMay = dctMap => {
        const strMapValueName = dctMap.Actions[0].ResultAs;
        return jsoDoScript([
            dctMap,
            { // Map value returned as lines of text
                "Variable": strMapValueName,
                "MacroActionType": "SetVariableToText",
                "Text": "%Variable%Local_ACCUMULATOR%"
            }
        ]) ? just(
            'Map evaluated to: ' +
            Application("Keyboard Maestro Engine")
            .getvariable(strMapValueName)
        ) : nothing('Map could not be evaluated');
    };

    // MAIN ------------------------------------------------------------------
    const
        mbInstance = kmInstanceMay(),
        strUUID = mbInstance.nothing ? (
            // NB REPLACE THIS UUID, FOR TESTING THE CODE OUTSIDE AN
            // EXECUTE JAVASCRIPT ACTION, WITH THE UUID OF THE HOST MACRRO
            // OBTAINED IN THE KM EDITOR WITH (Edit > Copy As > Copy UUID)
            'A930D513-B651-4C39-A53F-0E082E569AA0'
            // (At run-time the correct UUID is obtained automatically).
        ) : Application("Keyboard Maestro Engine")
        .getvariable('LocalUUID', {
            instance: mbInstance.just
        }),
        mbMapEvaln =
        bindMay(
            bindMay(
                bindMay(
                    bindMay(
                        macroForUUIDMay( // Macro in which this is running.
                            strUUID
                        ),
                        groupMay // JS grouped with disabled FOR EACH Action
                    ),
                    forEachMay // For Each template (inactive)
                ),
                mapActionMay // Map action derived from For Each template
            ),
            mapEvalnMay // Success or failure of map evaluation
        );

    // EXECUTE A THE TRANSLATION OF (DISABLED) 'FOR' ACTION
    // INTO A MAP

    return show(2,
        mbMapEvaln.nothing ? (
            mbMapEvaln.msg
        ) : mbMapEvaln.just
    );
})();