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

I'd like to get a list of macros in a given macro group without the KM Editor being activated.

The method I know of is to script the KM Editor. This will require the editor to be activated:

	tell application "Keyboard Maestro"
		tell its macro group "Macro Group Name"
			set MacroIdList to id of every macro
			set MacroNameList to name of every macro
		end tell
	end tell

I guess it's probably possible to get from the macro file. But I don't know how.

My ultimate purpose is to execute the macro that is

  1. located in a given macro group, and
  2. its name begins with a given string

Thanks!

1 Like

Take a look at this macro by @DanThomas. I believe it gets the macro list directly from the KM Macros plist file.
MACRO: Execute Macro by Name (Spotlight)

1 Like

Thanks. JXA is over my head. I only learned some JS.
I can get the XML file in AppleScript from here:

But I don't know how to move forward.
I did some search and saw AppleScript can do XML search. But I have not made a successful attempt yet.
A regex search might also serve my need. It'll involve sed in shell script. I'm a novice to all of them. Dan's JXA example is excellent. I have not found a similar example in AppleScript.

I missed this in my first reply.
The macro by @DanThomas that I gave you does most of what you want.
If you append a text tag to the macros of interest that IDs the Macro Group, then you would have mostly what you want.
For Example, I use "@OL" to ID macros in my Outlook Group.
Display Outlook Signatures @OL

So I can type "@OL" to list all macros in the Outlook Group, and then type some characters in the name of those macros.

The issue is: I want to do everything in AppleScript. So, no search box, no UI at all.

Hey Martin,

You can get the macros without parsing the plist file:

--------------------------------------------------------
# Auth: Christopher Stone
# dCre: 2021/03/09 23:32
# dMod: 2021/03/09 23:32 
# Appl: Keyboard Maestro Engine, BBEdit
# Task: Send Macro XML to BBEdit
# Libs: None
# Osax: None
# Tags: @Applescript, @Script, @BBEdit, @Keyboard_Maestro_Engine, @Send, @Macro, @XML
--------------------------------------------------------

tell application "Keyboard Maestro Engine"
   set macroList to getmacros with asstring
end tell

bbeditNewDoc(macroList, true)

--------------------------------------------------------
--» HANDLERS
--------------------------------------------------------
on bbeditNewDoc(_text, _activate)
   tell application "BBEdit"
      set newDoc to make new document with properties {text:_text, bounds:{0, 44, 1920, 1200}}
      tell newDoc
         select insertion point before its text
      end tell
      if _activate = true or _activate = 1 or _activate = "activate" then activate
   end tell
end bbeditNewDoc
--------------------------------------------------------

And you can do sophisticated search and search/replace with AppleScript:

You can work the XML with AppleScriptObjC or a number of other tools.

So you should be able to do what you want, if you're willing to put in the skull-sweat.

-Chris

3 Likes

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