Can I determine a Macro UUID from an Action UID?

The Engine.log is populated with entries that include ActionUID's. Is there any way to directly determine the associated macro UUID from one of these ActionUID's?

I do know that I can use the following AppleScript code to select the action (with the associated ActionUID).

### Requires Keyboard Maestro 8.0.3+ ###
set kmInst to system attribute "KMINSTANCE"
tell application "Keyboard Maestro Engine"
	set kmAction to getvariable "local_Action" instance kmInst
end tell

tell application "Keyboard Maestro"
	selectAction kmAction
	activate
end tell

After the action is selected, I also know that I could determine the macro by referencing the selected macro.

But my question is: if I have an ActionUID, can I directly determine the macro name or UUID?

1 Like

You would have to search for it.

My AppleScript foo is weak, but this works:

tell application "Keyboard Maestro"
	repeat with m in macros
		try
			action id "12345" of m
			set found to id of m
		end try
	end repeat
	found
end tell
2 Likes

Or, consuming Apple Events a little more parsimoniously, in deference to the efficiency lobby:

Macro containing given ActionUID.kmmacros (4.4 KB)


Expand disclosure triangle to view JS source
return (() => {
    "use strict";

    const main = () =>
        macroNameFromActionUidLR(kmvar.local_ActionUID);

    const macroNameFromActionUidLR = actionUID => {
        const
            ms = Application("Keyboard Maestro").macros,
            actionIDLists = ms.actions.id(),
            iMacro = actionIDLists.findIndex(
                ids => ids.includes(actionUID)
            );

        return either(
            alert("Macro containing given ActionUID")
        )(
            x => x
        )(
            -1 === iMacro
                ? Left(
                    [
                        "No macro found containing ActionUID:",
                        ` "${kmvar.local_ActionUID}"`
                    ]
                    .join("")
                )
                : Right(
                    (() => {
                        const macro = ms.at(iMacro);

                        return [
                            macro.name(),
                            macro.id()
                        ]
                        .join("\n");
                    })()
                )
        );
    };

    // ----------------------- 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
    });


    // 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 => "Left" in e
            ? fl(e.Left)
            : fr(e.Right);

    return main();
})();
1 Like

@peternlewis and @ComplexPoint, thanks for the replies!

It seems to work (and also @ComplexPoint's JXA), if the action is at the top level. But if an action is within a Repeat action (or I suspect any other group), neither seem to work.

In the script:

### Requires Keyboard Maestro 8.0.3+ ###
set kmInst to system attribute "KMINSTANCE"
tell application "Keyboard Maestro Engine"
	set kmAction to getvariable "local_Action" instance kmInst
end tell

tell application "Keyboard Maestro"
	selectAction kmAction
	activate
end tell

selectAction does not seem to have that limitation.

1 Like

XPATH over the plist XML (perhaps within XQuery or XSLT) would be my next thought, though it might take a little experimentation to get the details clean and efficient.

I leave the details as an exercise for the reader, but here's a first rough sketch (still overproducing and yielding the name of the enclosing Macro Group, as well as that of the Macro, I think.

QUERY DRAFT -- Macro containing given ActionUID.kmmacros (8.5 KB)


Expand disclosure triangle to view JS source
(() => {
    "use strict";

    const kmvar = {"local_ActionUID": "21421"};

    // Rob Trew @2024
    // Ver 0.01


    ObjC.import("AppKit");

    // main :: IO ()
    // eslint-disable-next-line max-lines-per-function
    const main = () => {
        const uw = ObjC.unwrap;

        const xquery = [
            "for $x in //key[\"ActionUID\"=text()]/",
            `following-sibling::real["${kmvar.local_ActionUID}"=text()]`,
            "return $x/ancestor-or-self::dict/",
            "key[\"Name\"=text()]/following-sibling::string[1]"
        ]
        .join("\n");

        return either(
            alert("XQuery report over KM Macros plist")
        )(
            xs => uw(xs).map(x => uw(x.stringValue))
            .join("\n")
        )(
            bindLR(
                readPlistFileLR(kmPlistPath())
            )(
                dict => bindLR(
                    plistFromDictLR(dict)
                )(
                    xml => bindLR(
                        xmlDocFromStringLR(xml)
                    )(
                        xQueryOverDocLR(xquery)
                    )
                )
            )
        );
    };


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

    // kmPlistPath :: () -> IO FilePath
    const kmPlistPath = () => {
        const
            kmMacros = [
                "/Keyboard Maestro/",
                "Keyboard Maestro Macros.plist"
            ].join("");

        return `${applicationSupportPath()}${kmMacros}`;
    };

    // ----------------------- 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
            );
        };


    // applicationSupportPath :: () -> String
    const applicationSupportPath = () => {
        const uw = ObjC.unwrap;

        return uw(
            uw($.NSFileManager.defaultManager
            .URLsForDirectoryInDomains(
                $.NSApplicationSupportDirectory,
                $.NSUserDomainMask
            )
            )[0].path
        );
    };


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

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


    // 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);


    // plistFromDictLR :: JS Object -> Either String XML
    const plistFromDictLR = jso => {
        const error = $();
        const xml = $.NSString.alloc.initWithDataEncoding(
            $.NSPropertyListSerialization
            .dataWithPropertyListFormatOptionsError(
                $(jso),
                $.NSPropertyListXMLFormat_v1_0, 0,
                error
            ),
            $.NSUTF8StringEncoding
        );

        return xml.isNil()
            ? Left(error.localizedDescription)
            : Right(
                ObjC.unwrap(xml)
            );
    };


    // readPlistFileLR :: FilePath -> Either String Dict
    const readPlistFileLR = fp =>
        // Either a message or a dictionary of key-value
        // pairs read from the given file path.
        bindLR(
            doesFileExist(fp)
                ? Right(filePath(fp))
                : Left(`No file found at path:\n\t${fp}`)
        )(
            fpFull => {
                const
                    e = $(),
                    maybeDict = $.NSDictionary
                    .dictionaryWithContentsOfURLError(
                        $.NSURL.fileURLWithPath(fpFull),
                        e
                    );

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

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


    // xmlDocFromStringLR ::
    // XML String -> Either String NSXMLDocument
    const xmlDocFromStringLR = xml => {
        const
            error = $(),
            xmlDoc = $.NSXMLDocument.alloc
            .initWithXMLStringOptionsError(
                xml, 0, error
            );

        return xmlDoc.isNil()
            ? Left(ObjC.unwrap(error.localizedDescription))
            : Right(xmlDoc);
    };


    // xQueryOverDocLR :: XQuery String ->
    // XMLDoc -> [String]
    const xQueryOverDocLR = xQuery =>
        // List of XQuery result strings for XQuery over doc.
        doc => {
            const
                uw = ObjC.unwrap,
                e = $(),
                xs = doc.objectsForXQueryError(xQuery, e);

            return xs.isNil()
                ? Left(uw(e.localizedDescription))
                : Right(xs);
        };


    // --------------------- 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);


    // 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);


    // MAIN ---
    return main();
})();
1 Like

PS I think perhaps looking for a Triggers key may sort the Macros from the Macro Groups.

Perhaps worth trying something like this:

for $x in //key["ActionUID"=text()]/following-sibling::real["21421"=text()]

return $x/ancestor-or-self::dict/key["Triggers"=text()]/
    following-sibling::key["UID"=text()]/following-sibling::string

and in the JS return value clause, using the .stringValue property, rather than the .XMLString property, which is useful during experimentation, but retains the surrounding tags;

xs => uw(xs).map(x => uw(x.stringValue))
.join("\n")
2 Likes

@ComplexPoint, thanks so much. Yes, that combination worked quite nicely to return the macro UUID. Of course I had to remove the hard-coded "21421".

For the sake of someone that might find this thread later, here's the revised macro per @ComplexPoint's instructions:


Download: QUERY DRAFT -- Macro containing given ActionUID-rev2.kmmacros (27 KB)

Macro-Image


Macro-Notes
  • Macros are always disabled when imported into the Keyboard Maestro Editor.
    • The user must ensure the macro is enabled.
    • The user must also ensure the macro's parent macro-group is enabled.

System Information
  • macOS 14.4.1 (23E224)
  • Keyboard Maestro v11.0.2

Now I'm studying the code in an attempt to understand it! :exploding_head:

1 Like

Where the hard-coded snippet above finds a match (// is a shorthand context for at any nesting level below the document root) for:

<key>ActionUID</key>

followed by a sibling:

<real>21421</real>

then a backward search up the path is specified by the context of

ancestor-or-self::

and if, on the ancestral path there turns out to be a <dict> containing anything of the form:

<key>Triggers</key>

then the next match is on any following sibling like:

<key>UID</key>

and where that turns up, our interest is in the next sibling with the shape of something like:

<string>023D6A6B-C9CE-4C9F-A18F-406C2C39678F</string>

2 Likes

Thanks again, @ComplexPoint.

After studying your code (with your assistance), I've made two changes:

  • Removed the IIFE. That's no longer needed in the the Execute a JavaScript For Automation action, right?

  • To return the macro name, rather than the UUID (Yes, I know, I changed my original question. :face_with_open_eyes_and_hand_over_mouth:), I changed the query to:

for $x in //key["ActionUID"=text()]/following-sibling::real["%Variable%local_ActionUID%"=text()]

return ($x/ancestor-or-self::dict/key["Name"=text()]/following-sibling::string)[1]

Download: QUERY DRAFT -- Macro containing given ActionUID-rev3.kmmacros (27 KB)

Macro-Image


Macro-Notes
  • Macros are always disabled when imported into the Keyboard Maestro Editor.
    • The user must ensure the macro is enabled.
    • The user must also ensure the macro's parent macro-group is enabled.

System Information
  • macOS 14.4.1 (23E224)
  • Keyboard Maestro v11.0.2

2 Likes

Yes, I used to drop them, when transferring code to a KM JS action, but it's since turned out to be easier leave them in place.

If you need to maintain, extend or test anything in Visual Studio Code, for example, you may find yourself having to restore the IIFE, and then decide whether or not to remove it again for pasting back into your action.

Not sure that the extra maintenance churn is really rewarded by much difference in performance, and leaving the IIFE in place has the additional advantage (beyond lowered maintenance and testing friction) of keeping the return in a single consistent place, where you can check its presence (for the KM 'Modern' syntax), at a glance.

+/- a leading return may turn out to be a simpler (testing and adjusting ⇄ use) switch than +/- (() => { ... })(),

but I would just try both approaches, and see which works, over time, for you.

1 Like

PS, to experiment with the costs, if any, of deeper IIFE nesting,
here is a JS expression wrapped 1000 levels deep.

(Seems to evaluate more or less instantly, from the perspective of this elderly mammal)

2 + 2 wrapped in 1000 levels of IIFE .kmmacros (26 KB)


Deeply IIFE-nested JS source was created with the following script:

Expand disclosure triangle to view JS source
(() => {
    "use strict";

    ObjC.import("AppKit");


    const main = () =>
        copyText(
            deepIIFEString(1000)(
                "2 + 2;"
            )
        );


    // -- DEEPLY NESTED SOURCE CODE COPIED TO CLIPBOARD --

    // deepIIFEString :: Int -> JS String -> JS String
    const deepIIFEString = depth =>
        source => {
            const go = s =>
                n => 0 < n
                    ? `(() => {\nreturn ${go(s)(n - 1)}\n})();`
                    : s;

            return `return ${go(source)(depth)}`;
        };


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

    // copyText :: String -> IO String
    const copyText = s => {
        const pb = $.NSPasteboard.generalPasteboard;

        return (
            pb.clearContents,
            pb.setStringForType(
                $(s),
                $.NSPasteboardTypeString
            ),
            s
        );
    };

    return main();
})();

Thank you for this script, Jim. Very helpful today! :+1: