Getting Timestamp of Most Recently Modified Macro

Howdy y’all,

Calling all AppleScript, shell script, JavaScript and any other guru out there to help out with something.

Essentially, I want to get a list of modification dates for every single macro in my library, epoch-formatted, sorted with most recently modified at the top. The approach I am taking works... but it’s SLOW. It takes about 5 seconds on my M2 MacBook Pro (because of the novice AppleScript). :man_facepalming:t2:

There has to be a better way to do this, but it eludes me at the moment. I’m open to suggestions, thanks in advance!

-Chris

Download Macro(s): Get last modified time stamp of all macros in library.kmmacros (4.8 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 13.5.1
  • Keyboard Maestro v10.2

Perhaps something like this ?

Download Macro(s): Time stamps of every macro sorted in descending order.kmmacros (3.2 KB)

JS Source Code

(() => {
    "use strict";

    // jxaContext :: IO ()
    const jxaContext = () => {
        const main = () => {
            const
                km = Application('Keyboard Maestro'),
                epochDates = km.macros.modificationDate().map(
                    x => new Date(x).getTime()
                );
            return sortBy(
                flip(compare)
            )(epochDates).join("\n")
        };

        // GENERICS ----------------------------------------------------------------
        // https://github.com/RobTrew/prelude-jxa
        // JS Prelude --------------------------------------------------
        // compare :: a -> a -> Ordering
        const compare = a =>
            b => a < b ? -1 : (a > b ? 1 : 0);

        // flip :: (a -> b -> c) -> b -> a -> c
        const flip = op =>
            // The binary function op with
            // its arguments reversed.
            1 !== op.length
                ? (a, b) => op(b, a)
                : (a => b => op(b)(a));

        // sortBy :: (a -> a -> Ordering) -> [a] -> [a]
        const sortBy = f =>
            // A copy of xs sorted by the comparator function f.
            xs => xs.slice()
                .sort((a, b) => f(a)(b));

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

    return jxaContext();
})();
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 13.4
  • Keyboard Maestro v10.2
6 Likes

Outstanding! That does exactly what I need and has me wanting to learn JavaScript... just can’t seem to find the time to do it. Thanks so much!

-Chris

2 Likes

And, of course, if you needed to at any point, you could extend @unlocked2412 's design to append the corresponding names:

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

    const main = () => {
        const
            km = Application("Keyboard Maestro"),
            macros = km.macros;

        return sortBy(
            flip(compare)
        )(
            zipWith(
                t => k => `${new Date(t).getTime()} -> ${k}`
            )(
                macros.modificationDate()
            )(
                macros.name()
            )
        )
        .join("\n");
    };


    // ------------------ GENERICS -------------------
    // https://github.com/RobTrew/prelude-jxa
    // ----------------- JS PRELUDE ------------------

    // compare :: a -> a -> Ordering
    const compare = a =>
        b => a < b ? -1 : (a > b ? 1 : 0);


    // flip :: (a -> b -> c) -> b -> a -> c
    const flip = op =>
    // The binary function op with
    // its arguments reversed.
        1 !== op.length
            ? (a, b) => op(b, a)
            : (a => b => op(b)(a));

    // sortBy :: (a -> a -> Ordering) -> [a] -> [a]
    const sortBy = f =>
    // A copy of xs sorted by the comparator function f.
        xs => xs.slice()
        .sort((a, b) => f(a)(b));


    // zipWith :: (a -> a -> b) -> [a] -> [b]
    const zipWith = f => {
        // A list with the length of the shorter of
        // xs and ys, defined by zipping with a
        // custom function, rather than with the
        // default tuple constructor.
        const go = xs =>
            ys => 0 < xs.length
                ? 0 < ys.length
                    ? [f(xs[0])(ys[0])].concat(
                        go(xs.slice(1))(ys.slice(1))
                    )
                    : []
                : [];

        return go;
    };


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

Wow, I didn't even know I wanted something like this! Thanks all.

2 Likes

Hey @ComplexPoint quick question... is there a way to include the macro UUIDs in the results?

There are a handful of macros that I would like to exclude from this list. I can do this easily enough by removing them from the list after the fact, but to do so reliably, I would like to do this having the list include the macro UUIDs.

If you just want three properties in the list, then I would use zipWith3, and if an arbitrary number then a ZipList object.

zipWith3 first:

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

    const main = () => {
        const
            km = Application("Keyboard Maestro"),
            macros = km.macros;

        return sortBy(
            flip(compare)
        )(
            zipWith3(
                t => k => u =>
                    `${new Date(t).getTime()} -> ${u} -> ${k}`
            )(
                macros.modificationDate()
            )(
                macros.name()
            )(
                macros.id()
            )
        )
        .join("\n");
    };


    // ------------------ GENERICS -------------------
    // https://github.com/RobTrew/prelude-jxa
    // ----------------- JS PRELUDE ------------------

    // compare :: a -> a -> Ordering
    const compare = a =>
        b => a < b ? -1 : (a > b ? 1 : 0);


    // flip :: (a -> b -> c) -> b -> a -> c
    const flip = op =>
    // The binary function op with
    // its arguments reversed.
        1 !== op.length
            ? (a, b) => op(b, a)
            : (a => b => op(b)(a));


    // sortBy :: (a -> a -> Ordering) -> [a] -> [a]
    const sortBy = f =>
    // A copy of xs sorted by the comparator function f.
        xs => xs.slice()
        .sort((a, b) => f(a)(b));


    // zipWith3 :: (a -> b -> c -> d) ->
    // [a] -> [b] -> [c] -> [d]
    const zipWith3 = f =>
        xs => ys => zs => Array.from({
            length: Math.min(
                ...[xs, ys, zs].map(x => x.length)
            )
        }, (_, i) => f(xs[i])(ys[i])(zs[i]));


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

Outstanding, this does exactly what I need. I can now filter the results to exclude specific macros, thanks so much!

2 Likes

FWIW you could specify a list of UUIDs to skip at source:

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

    // A list of UUID strings to skip.
    const exclusions = [
        // e.g. as comma-separated strings:
        // "DD24A07A-C07B-4A67-A1E6-0DF8569C473D",
        // "4F71F3BC-4F46-48B2-805F-AEC8EA77E418"
    ];

    const main = () => {
        const
            km = Application("Keyboard Maestro"),
            macros = km.macros;

        return sortBy(
            flip(compare)
        )(
            zipWith3(
                u => t => k =>
                    exclusions.includes(u)
                        ? []
                        : [`${new Date(t).getTime()} -> ${u} -> ${k}`]
            )(
                macros.id()
            )(
                macros.modificationDate()
            )(
                macros.name()
            )
            .flat()
        )
        .join("\n");
    };


    // ------------------ GENERICS -------------------
    // https://github.com/RobTrew/prelude-jxa
    // ----------------- JS PRELUDE ------------------

    // compare :: a -> a -> Ordering
    const compare = a =>
        b => a < b
            ? -1
            : a > b ? 1 : 0;


    // flip :: (a -> b -> c) -> b -> a -> c
    const flip = op =>
    // The binary function op with
    // its arguments reversed.
        1 !== op.length
            ? (a, b) => op(b, a)
            : (a => b => op(b)(a));


    // sortBy :: (a -> a -> Ordering) -> [a] -> [a]
    const sortBy = f =>
    // A copy of xs sorted by the comparator function f.
        xs => xs.slice()
        .sort((a, b) => f(a)(b));


    // zipWith3 :: (a -> b -> c -> d) ->
    // [a] -> [b] -> [c] -> [d]
    const zipWith3 = f =>
        xs => ys => zs => Array.from({
            length: Math.min(
                ...[xs, ys, zs].map(x => x.length)
            )
        }, (_, i) => f(xs[i])(ys[i])(zs[i]));


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

This just keeps getting better. :laughing:

For a little background... I have a macro that exports my entire KM library that runs on an idle trigger. But I don’t want it to fully execute every single time. So I have two conditions that it checks for: 1) if the last time it was run was within the last hour, and 2) if the most recently modified macro has not been modified since the last backup.

I use your JavaScript solution to get the most recently modified macros, and if the timestamp for the most recent one hasn’t changed since the last backup (because it saves the timestamp to a global DND variable), then it cancels. I exclude certain macros, because some of them are enabled or disabled as part of my workflow throughout the day, and I don’t want those “changes” to trigger another backup.

If that doesn’t make sense, just look at this screenshot 😅 (click to expand/collapse)

1 Like

Consider that I know next to nothing about JXA and JavaScript. Can your handy code be modified to allow me to pass in the name of a particular group, instead of running for all macros? And what if I wanted it to return not just the modification timestamp and the name (as shown in @ComplexPoint's example), but the UUID? I figured out that the field is macros.id, but adding a third field into the mix messes everything up.

thanks!

-rob.

Which one are you looking at above ?

This one, for example – post 9 above – does include UUID, but perhaps there is something else that you are after ?

Here, for example is a subroutine, and an EXAMPLE macro which uses it.

I happen to have a group called Bike – you will need to test it with the name of one of your macro groups

(Note, both Subroutine and EXAMPLE macro will need to be accessible)

Sorted JSON listing of Group Macros–subroutine and example.kmmacros (12,2 Ko)


For zipping lists of properties together, you will find, that in my JS Prelude library,

RobTrew/prelude-jxa: Generic functions for macOS and iOS scripting in Javascript

in addition to the simplest zipWith (which zips a pair of lists with a custom function) there is also a zipWith3 and a zipWith4 for three lists and four lists respectively.

In the subroutine above, however, I've used a ZipList approach which can be used with any number of lists (wrapping an additional application of apZL for each additional zipped list, where apZL boils down to zipWith(identity).

The results of that subroutine are, incidentally, returned in a JSON format, to which the Keyboard Maestro %JSONValue% token can be applied.


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

    // main :: IO()
    const main = () => {
        const
            groupName = kmvar.local_Macro_Group_Name,
            group = Application("Keyboard Maestro")
            .macroGroups.byName(groupName),
            sortDirection = toUpper(
                kmvar.local_Ascending_or_Descending
            )
            .startsWith("ASC")
                ? identity
                : flip,
            iKey = Number(
                kmvar.local_Zero_based_Sort_Key_Index
            ),
            apZL = zipWith(identity);

        return either(
            alert(`Macros in group '${groupName}'`)
        )(
            sortBy(
                sortDirection(comparing(x => x[iKey]))
            )
        )(
            fmapLR(macros =>
                apZL(
                    apZL(
                        macros.name()
                        .map(name => stamp => uuid => [
                            name, stamp, uuid
                        ])
                    )(
                        macros.modificationDate()
                    )
                )(
                    macros.id()
                )
            )(
                group.exists()
                    ? Right(group.macros)
                    : Left(
                        `Group not found as spelled: '${groupName}'.`
                    )
            )
        );
    };


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


    // comparing :: Ord a => (b -> a) -> b -> b -> Ordering
    const comparing = f =>
        // The ordering of f(x) and f(y) as a value
        // drawn from {-1, 0, 1}, representing {LT, EQ, GT}.
        x => y => {
            const
                a = f(x),
                b = f(y);

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


    // flip :: (a -> b -> c) -> b -> a -> c
    const flip = op =>
        // The binary function op with
        // its arguments reversed.
        1 !== op.length
            ? (a, b) => op(b, a)
            : (a => b => op(b)(a));


    // fmapLR (<$>) :: (b -> c) -> Either a b -> Either a c
    const fmapLR = f =>
        // Either f mapped into the contents of any Right
        // value in e, or e unchanged if is a Left value.
        e => "Left" in e
            ? e
            : Right(f(e.Right));


    // identity :: a -> a
    const identity = x =>
    // The identity function.
        x;


    // sortBy :: (a -> a -> Ordering) -> [a] -> [a]
    const sortBy = f =>
        // A copy of xs sorted by the comparator function f.
        xs => xs.slice()
        .sort((a, b) => f(a)(b));


    // toUpper :: String -> String
    const toUpper = s =>
        s.toLocaleUpperCase();


    // zipWith_ :: (a -> a -> b) -> [a] -> [b]
    const zipWith = f =>
        // A list with the length of the shorter of
        // xs and ys, defined by zipping with a
        // custom function, rather than with the
        // default tuple constructor.
        xs => ys => xs.slice(
            0, Math.min(xs.length, ys.length)
        )
        .map((x, i) => f(x)(ys[i]));


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

    // --------------------- LOGGING ---------------------

    // sj :: a -> String
    const sj = (...args) =>
    // Abbreviation of showJSON for quick testing.
    // Default indent size is two, which can be
    // overriden by any integer supplied as the
    // first argument of more than one.
        JSON.stringify.apply(
            null,
            1 < args.length && !isNaN(args[0])
                ? [args[1], null, args[0]]
                : [args[0], null, 2]
        );

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

Jeez, I was so tired yesterday I didn't even notice the three example was already there! (I grabbed the one that added the name.)

Thanks for this, will download and take a look.

-rob.

1 Like

How would I modify this javascript to display the date in human readable form?

Like: 2024-05-11

I know nothing about JavaScript coding, so please be explicit.

Thanks!

It's currently displayed in this form:

2024-05-10T05:23:26.000Z

(This is close, bar the T and the Z, to ISO 8601 which is certainly designed to be human readable ...)

Are you hoping to simply drop the time and time zone,
(to the right of the T)
or are you aiming to display them with gaps ?

(or perhaps display something more elaborate and locale-specific like
Wed Nov 23 2022 19:32:03 GMT+0000 (Greenwich Mean Time) ?)

This version aims to adjust the date to what I think of as a "TaskPaper date string" (ISO 8601 with more space and less detail) like:

2024-05-11 18:17

SUBROUTINES Macros.kmmacros (13 KB)

by adjusting the JS code in the subroutine – wrapping the stamp value in a TaskPaperDateString function, which looks like:

Expand disclosure triangle to view JS source
// taskPaperDateString :: Date -> String
const taskPaperDateString = dte => {
    const [d, t] = iso8601Local(dte).split("T");

    return [d, t.slice(0, 5)].join(" ");
};

// iso8601Local :: Date -> String
const iso8601Local = dte =>
    new Date(dte - (6E4 * dte.getTimezoneOffset()))
    .toISOString();

Hmmm. Here's what it looks like when I run the macro:

I presume the string of numbers is the epoch dates? I’d just like the list to show my local OS's date when I last modified each macro, like: 2024-04-19

Thanks,
Russell

Which macro is figuring as the macro in this context ?

( There are now a few above :slight_smile: )

Sorry! I now see that after Post 2, which contained a real macro, all the rest of the posts included variations on the Javascript I could insert into that macro.

So, I downloaded the "SUBROUTINES Macros" you put in Post 17, edited the main macro to search one of my groups, and got dates I can read, which is GREAT.

Now, how can I modify these two macros to search all groups? (or put your date format code into the macro from Post 2)

Thanks,
Russell