AppleScript: How to Get a List of Macros in a Macro Group without the KM Editor Being Activated?

Thanks a lot @ccstone. You keep recommending stuffs and I keep downloading them. Often I check my storage and/or clear the trash bin as my Mac is installing those stuffs. I know they are very light and probably won't have a noticeable effect to the hard drive storage, but I just can't help. :joy:

The link in that post no longer works. but I've found the correct download link:

1 Like

Great point, script, and reminder!
I've been working so much with the KM Editor scripts that I completely forgot about this command.

Like so many things in KM, there are often multiple ways to get the job done.

EDIT 2021-03-10 08:54 GMT-6:

Chris, I was expecting a list macros, but this returns XML of all macros. So you would still need to parse the XML to get a pure list of macro names.

From the KM Engine Script Dict:
getmacros (verb)Gets an array of groups of arrays of macros.

I was wondering about that too.
The extra handler only opens a new doc in BBEdit and puts the entire XML file there. But I surmise maybe that was all that Chris means by "get the macros without parsing the plist file".

I'm just demonstrating getting the XML.

Parsing is up to the user.

Although, I think I might have a script somewhere that does it – no, not finding one. I thought that I'd done this with the shell, but I can't find it right now.

@DanThomas' Go To Macro by Name (Spotlight) does this with JavaScript I believe, so you have a starting point.

Keep in mind that getmacros gets all macros.

Gethotkeys on the other hand gets only the macros available in the current context that have a hotkey or type-string trigger.

-Chris

1 Like

I actually found some posts parsing the XML with AppleScript, e.g.,

Also, there are posts about using Shell scripts for RegEx:

and using AppleScript Objective-C for RegEx:

But I was not able to twist my codes to get the desired result.
After a couple of hours, I had to quit. I can't understand the example that uses AppleScript Objective-C. I have not studied anything about Obj-C.

Tho there's no need to parse the XML – that's done for free by built-in methods like NSArray.arrayWithContentsOfURLError()

With an Execute JavaScript for Automation action for example:

Listing of Macro groups and their macro counts from KM XML.kmmacros (23.6 KB)

JS Source
(() => {
    "use strict";

    // Reading the KM XML from KMEngine.getmacros()

    // Rob Trew @2021

    // main :: IO ()
    const main = () => {
        const
            fpTemp = writeTempFile("km.xml")(
                Application("Keyboard Maestro Engine")
                .getmacros({
                    asstring: true
                })
            );

        return either(
            msg => alert("Reading KM XML")(msg)
        )(
            groups => {
                const
                    title = `${groups.length} KM Macro Groups:`,
                    listing = groups.map(group => {
                        const
                            n = group.macros.length,
                            units = plural("macro")(
                                n
                            );

                        return `${group.name} :: ${n} ${units}`;
                    }).join("\n");

                return `${title}\n\n${listing}`;
            }
        )(
            readPlistArrayFileLR(fpTemp)
        );
    };

    // ----------------------- JXA -----------------------

    // alert :: String => String -> IO String
    const alert = title =>
        s => {
            const sa = Object.assign(
                Application("System Events"), {
                    includeStandardAdditions: true
                });

            return (
                sa.activate(),
                sa.displayDialog(s, {
                    withTitle: title,
                    buttons: ["OK"],
                    defaultButton: "OK"
                }),
                s
            );
        };

    // --------------------- GENERIC ---------------------

    // Left :: a -> Either a b
    const Left = x => ({
        type: "Either",
        Left: x
    });


    // Right :: b -> Either a b
    const Right = x => ({
        type: "Either",
        Right: x
    });


    // bindLR (>>=) :: Either a ->
    // (a -> Either b) -> Either b
    const bindLR = m =>
        mf => m.Left ? (
            m
        ) : mf(m.Right);


    // doesFileExist :: FilePath -> IO Bool
    const doesFileExist = fp => {
        const ref = Ref();

        return $.NSFileManager.defaultManager
            .fileExistsAtPathIsDirectory(
                $(fp)
                .stringByStandardizingPath, ref
            ) && 1 !== ref[0];
    };


    // either :: (a -> c) -> (b -> c) -> Either a b -> c
    const either = fl =>
        // Application of the function fl to the
        // contents of any Left value in e, or
        // the application of fr to its Right value.
        fr => e => e.Left ? (
            fl(e.Left)
        ) : fr(e.Right);


    // filePath :: String -> FilePath
    const filePath = s =>
        // The given file path with any tilde expanded
        // to the full user directory path.
        ObjC.unwrap(ObjC.wrap(s)
            .stringByStandardizingPath);


    // last :: [a] -> a
    const last = xs =>
        // The last item of a list.
        0 < xs.length ? (
            xs.slice(-1)[0]
        ) : null;


    // plural :: String -> Int -> String
    const plural = k =>
        // Singular or plural EN inflection
        // of a given word.
        n => 1 !== n ? (
            `${k}s`
        ) : k;


    // readPlistArrayFileLR :: FilePath -> Either String Object
    const readPlistArrayFileLR = fp =>
        bindLR(
            doesFileExist(fp) ? (
                Right(filePath(fp))
            ) : Left(`No file found at path:\n\t${fp}`)
        )(fpFull => {
            const
                e = $(),
                maybeDict = (
                    $.NSArray
                    .arrayWithContentsOfURLError(
                        $.NSURL.fileURLWithPath(fpFull),
                        e
                    )
                );

            return maybeDict.isNil() ? (() => {
                const msg = ObjC.unwrap(e.localizedDescription);

                return Left(`readPlistFileLR:\n\t${msg}`);
            })() : Right(ObjC.deepUnwrap(maybeDict));
        });

    // takeBaseName :: FilePath -> String
    const takeBaseName = fp =>
        ("" !== fp) ? (
            ("/" !== fp[fp.length - 1]) ? (() => {
                const fn = fp.split("/").slice(-1)[0];

                return fn.includes(".") ? (
                    fn.split(".").slice(0, -1)
                    .join(".")
                ) : fn;
            })() : ""
        ) : "";


    // takeExtension :: FilePath -> String
    const takeExtension = fp => (
        fs => {
            const fn = last(fs);

            return fn.includes(".") ? (
                `.${last(fn.split("."))}`
            ) : "";
        }
    )(fp.split("/"));


    // writeFile :: FilePath -> String -> IO ()
    const writeFile = fp => s =>
        $.NSString.alloc.initWithUTF8String(s)
        .writeToFileAtomicallyEncodingError(
            $(fp)
            .stringByStandardizingPath, false,
            $.NSUTF8StringEncoding, null
        );

    // writeTempFile :: String -> String -> IO FilePath
    const writeTempFile = template =>
        // File name template -> string data -> IO temporary path
        txt => {
            const
                fp = ObjC.unwrap($.NSTemporaryDirectory()) +
                takeBaseName(template) + Math.random()
                .toString()
                .substring(3) + takeExtension(template);

            return (writeFile(fp)(txt), fp);
        };

    // MAIN ---
    return main();
})();
2 Likes

Example 2 :: listing the macros of a named KM group

Listing the macros of a named KM Group.kmmacros (24.4 KB)

JS Source
(() => {
    "use strict";

    // Reading the KM XML from KMEngine.getmacros()

    // Example two :: listing the macros of a named group.

    // Rob Trew @2021

    // main :: IO ()
    const main = () => {
        const
            kme = Application("Keyboard Maestro Engine"),
            groupName = kme.getvariable("groupName"),
            fpTemp = writeTempFile("km.xml")(
                kme.getmacros({
                    asstring: true
                })
            );

        return either(
            msg => alert("Listing named KM group")(msg)
        )(
            report => report
        )(
            bindLR(
                readPlistArrayFileLR(fpTemp)
            )(
                groups => {
                    const
                        groupIndex = groups.findIndex(
                            group => groupName === group.name
                        );

                    return -1 !== groupIndex ? (() => {
                        const
                            group = groups[groupIndex],
                            macros = group.macros,
                            title = `${groupName} group:`,
                            listing = macros.map(macro => {
                                const
                                    name = macro.name,
                                    n = macro.used,
                                    units = plural("time")(n);

                                return `\t- ${name}    (used ${n} ${units})`;
                            }).join("\n");

                        return Right(`${title}\n\n${listing}`);
                    })() : Left(`No group found with name: ${groupName}`);
                }
            )
        );
    };

    // ----------------------- JXA -----------------------

    // alert :: String => String -> IO String
    const alert = title =>
        s => {
            const sa = Object.assign(
                Application("System Events"), {
                    includeStandardAdditions: true
                });

            return (
                sa.activate(),
                sa.displayDialog(s, {
                    withTitle: title,
                    buttons: ["OK"],
                    defaultButton: "OK"
                }),
                s
            );
        };

    // --------------------- GENERIC ---------------------

    // Left :: a -> Either a b
    const Left = x => ({
        type: "Either",
        Left: x
    });


    // Right :: b -> Either a b
    const Right = x => ({
        type: "Either",
        Right: x
    });


    // bindLR (>>=) :: Either a ->
    // (a -> Either b) -> Either b
    const bindLR = m =>
        mf => m.Left ? (
            m
        ) : mf(m.Right);


    // doesFileExist :: FilePath -> IO Bool
    const doesFileExist = fp => {
        const ref = Ref();

        return $.NSFileManager.defaultManager
            .fileExistsAtPathIsDirectory(
                $(fp)
                .stringByStandardizingPath, ref
            ) && 1 !== ref[0];
    };


    // either :: (a -> c) -> (b -> c) -> Either a b -> c
    const either = fl =>
        // Application of the function fl to the
        // contents of any Left value in e, or
        // the application of fr to its Right value.
        fr => e => e.Left ? (
            fl(e.Left)
        ) : fr(e.Right);


    // filePath :: String -> FilePath
    const filePath = s =>
        // The given file path with any tilde expanded
        // to the full user directory path.
        ObjC.unwrap(ObjC.wrap(s)
            .stringByStandardizingPath);


    // last :: [a] -> a
    const last = xs =>
        // The last item of a list.
        0 < xs.length ? (
            xs.slice(-1)[0]
        ) : null;


    // plural :: String -> Int -> String
    const plural = k =>
        // Singular or plural EN inflection
        // of a given word.
        n => 1 !== n ? (
            `${k}s`
        ) : k;


    // readPlistArrayFileLR :: FilePath -> Either String Object
    const readPlistArrayFileLR = fp =>
        bindLR(
            doesFileExist(fp) ? (
                Right(filePath(fp))
            ) : Left(`No file found at path:\n\t${fp}`)
        )(fpFull => {
            const
                e = $(),
                maybeDict = (
                    $.NSArray
                    .arrayWithContentsOfURLError(
                        $.NSURL.fileURLWithPath(fpFull),
                        e
                    )
                );

            return maybeDict.isNil() ? (() => {
                const msg = ObjC.unwrap(e.localizedDescription);

                return Left(`readPlistFileLR:\n\t${msg}`);
            })() : Right(ObjC.deepUnwrap(maybeDict));
        });

    // takeBaseName :: FilePath -> String
    const takeBaseName = fp =>
        ("" !== fp) ? (
            ("/" !== fp[fp.length - 1]) ? (() => {
                const fn = fp.split("/").slice(-1)[0];

                return fn.includes(".") ? (
                    fn.split(".").slice(0, -1)
                    .join(".")
                ) : fn;
            })() : ""
        ) : "";


    // takeExtension :: FilePath -> String
    const takeExtension = fp => (
        fs => {
            const fn = last(fs);

            return fn.includes(".") ? (
                `.${last(fn.split("."))}`
            ) : "";
        }
    )(fp.split("/"));


    // writeFile :: FilePath -> String -> IO ()
    const writeFile = fp => s =>
        $.NSString.alloc.initWithUTF8String(s)
        .writeToFileAtomicallyEncodingError(
            $(fp)
            .stringByStandardizingPath, false,
            $.NSUTF8StringEncoding, null
        );

    // writeTempFile :: String -> String -> IO FilePath
    const writeTempFile = template =>
        // File name template -> string data -> IO temporary path
        txt => {
            const
                fp = ObjC.unwrap($.NSTemporaryDirectory()) +
                takeBaseName(template) + Math.random()
                .toString()
                .substring(3) + takeExtension(template);

            return (writeFile(fp)(txt), fp);
        };

    // MAIN ---
    return main();
})();
2 Likes

Thanks, @ComplexPoint.
I saw a similar JXA code in @DanThomas's macro referred to by @JMichaelTX in the first reply.
My question is: Is there a way to use run these JXA codes in AppleScript, to pass the JXA result to a variable in AppleScript?
Or, is there a similar code for AppleScript to do the same thing as the JXA codes do?

JavaScript is a handier instrument for this because the parse of the XML maps straight through to the JSON object structure.

AppleScript's problem here is not so much that it's relatively slow (which may not matter much) but that AppleScript records are a bit fragile and tricky to work with.

  • If you ask a JS object / dictionary for the value of a key which it doesn't have, it just returns the special value undefined.
  • If you do that to an AppleScript record, it trips an error.

This would be more manageable if we could easily ask an AppleScript record what keys it does have.

  • In JS you can do this directly with the built-in Object.keys() and Object.getOwnPropertyNames(),
  • but in AS that's not a native possibility, though there are some slightly more fiddly routes through the foreign function interface to NSFoundation etc.

The bottom line is that if you want to work with KM XML, using AppleScript may not really be the simplifying choice.

One approach, if you are invested in AppleScript, may be to:

  • Read the XML and extract values using Execute JS actions,
  • then bind those values to KM variable names,
  • and read the KM variables from AS.
1 Like

Thanks a lot. This explanation is very helpful!
I saw in some places people prefer AppleScript over JXA. In this case, JXA apparently wins the case.

IMO, the only advantage that AppleScript has over JXA is the availability of a great script IDE and debugger: Script Debugger 7.

As I have said before, I much prefer JavaScript as a language over the language of AppleScript. So, if you are just now learning either AppleScript or JXA, I would suggest JXA. The Apple Script Editor, which is required for JXA, can use the Safari-based JavaScript debugger.

1 Like

I learned a little bit AppleScript to do some simple jobs.
I have not learned anything about JXA yet. But I did learn some basic JavaScript to work with HTML. I'll try JXA in the future. Thanks for your recommendation!

The JavaScript core is the same for web (HTML) as it is for JXA (apps).
In one case you are working with the DOM (HTML Document Object Model), and in the other you are working with macOS app model.
See JXA Resources.

Here's a teaser.

Starting with @DanThomas' JXA script:

    items = items.filter(function(item) {
      return item.macroName.indexOf("---") < 0 &&
        !item.macroName.endsWith("[Quick Macro]");
    }).sort(function(a, b) {
      var result = caseInsensitiveNaturalCompareForStrings(a.macroName, b.macroName);
      if (result === 0)
        result = caseInsensitiveNaturalCompareForStrings(a.groupName, b.groupName);
      return result;
    });

    var macroList = items;
    
    //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    //  Return List of Macros as JSON String    ### ADD JMTX
    //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    
    //--- MACRO FILTER DATA ---
    var groupNameToFind = '[GLOBAL]';
    var macroNameContains  = 'BBEdit';
    
    var macroListFiltered = macroList.filter(function(el) {
      return (el['groupName'] === groupNameToFind) &&  (el['macroName'].indexOf(macroNameContains) !== -1)});
    
    var macroListStr = JSON.stringify(macroListFiltered, undefined, 4);
    return macroListStr;

would return a JSON string like this:

[
    {
        "macroUUID": "0A0459E3-D241-401C-96AF-AE72CC68DCA0",
        "macroName": "@BBEdit COPY Current Selection to BBEdit",
        "groupName": "[GLOBAL]",
        "triggers": "; Triggers: ⌃⌥⌘B | \nMenu"
    },
    {
        "macroUUID": "31FFE244-C000-4967-98D4-AC6E1C3618E9",
        "macroName": "Mover List with Prompt with List and BBEdit [Sub-Macro]",
        "groupName": "[GLOBAL]",
        "triggers": ""
    }
]

It would be very easy from there to get a simple list of Macros.

1 Like

Hi @JMichaelTX,
Thank you so much for taking the time to post these examples and referring me to the resource page you created.

The JXA code for getting the macro list was a bit too complex for me. But with some extra googling and reading, I think I've understood most part. Only one thing puzzles me: indexOf("---").
I don't know what "---" is supposed to do?

I read two articles on your resource page that are written for beginners. They are VERY helpful! Now I have a general idea about how JXA interacts with other apps.

To provide a coder-friendly JXA script that you and others can use to learn and modify, I have provided this macro/script:

MACRO: Get List of KM Macro Names Filtered By Macro Group and Macro Name [Example]

To get JavaScript coding help on almost any term or expression, just do a Google search with:
"JavaScript <and whatever you want help on."

If have found this very very often provides the info/help I need.

Let me know if you have any JXA questions.

I used this in Macro Reporter, Rob, where it functions in an Execute for JavaScript for Automation action as you have it here.

That causes the HTML window of that macro to redraw each time a new group is selected from the popup for a report in the text area below the popup. I've been trying to get around that by folding the JS into the HTML prompt.

But I keep tripping over things. I avoided the Application call (and the getmacros call) with a local variable created in the AppleScript that gets all the group names to begin with.

But then I run into ObjC not being available. Even when I load AppKit. So either I'm lost or it just can't be done.

Somehow I'd like to get from the local variable with the Keyboard Maestro plist XML of all the macros to the HTML list of the macros for the selected group without redrawing the HTML prompt (using a JS function in the HTML head).

Now that I've spun my blinded self around three times, can you point me in the right direction.

Here's as far as I've gotten:

Macro Reporter 2.0a.kmmacros (16.8 KB)

1 Like

Pushing on deadline here, but I'll take a look later in the week.

Briefly, though, the ObjC interface is only exposed to the special JSContext set up for osascript – you can't get to it from a browser instance of a JS interpreter (i.e. you can't get to it from JS executing in a custom HTML action).

On the other hand, you should be able to:

  • Generate the data in an osascript context (like an Execute JXA action) using ObjC methods, and store it in a KM variable,
  • then pull it into the Custom HTML action browser JS context using window.KeyboardMaestro.GetVariable

Ah, hadn't thought of dismembering the code like that, storing the JavaScript object in a local variable for parsing in the HMTL prompt. I have a premonition the code won't survive my surgery, but I'll give it a shot later today. Thanks.

I managed to store the macros in a local variable before displaying the HTML prompt but that revealed a problem with this approach. The data was over 500K from my 1.1-MB plist file. And I suspect I'm on the small side with my macro file.

Thinking it over, I think a better approach would be to revise your function to return JSON-formatted local variable with just a few details from the full macro file for all the macros.

Something like this (but not prettified) which my previous variation of your function already collects:

{ "macro" : [
	{
		"group":,
		"name":,
		"enabled":,
		"used":,
		"triggers":,
	},
]}

That should be a lot smaller variable than what I had (especially if not prettified).

Then for any group selected from the HTML prompt's popup, I can write a function to list anything in the JSON list with that group name, format it as HTML and not have to redraw the HTML window.

The catch is I don't see how to modify your function to return all the macros with their group names and not just ones for a specific group.