Markdown listing of all macros, by group and name, with any initial comment

A draft script which places a sorted Markdown list of all your Keyboard Maestro macros, by group and name, in the clipboard.

( Marked 2 can generate rich text and PDF from markdown clipboards )

(If the first action is a comment, also includes the comment title and text).

Reads the main Keyboard Maestro Macros.plist directly, so the script should not be used unless that file is fully backed up.

Yosemite only – uses Javascript for Applications.

Markdown listing of all macros, with any initial comments.kmmacros (20.2 KB)

Uncompressed source:

// Markdown listing of Keyboard Maestro library
// Sorted by Groups and Macro names
// Includes any initial comment (i.e. if first action of Macro is comment)

// CAUTION: Reads directly from the user's main Keyboard Maestro Macros.plist.
// 
// Even read-only operations should only be done on important data
// IF IT IS WELL BACKED UP

// Back up your KM data **before** running this script

/****
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
****/

// Rob Trew, MIT license, 2015

// draft 0.3 Add comments, slight tidy

function run() {

  var a = Application.currentApplication(),
    sa = (a.includeStandardAdditions = true, a);

  // Keyboard Maestro Macros.plist or specified file read to Markdown
  // MaybePath || PathToMain plist --> Markdown --> Markdown Clipboard
  function kmMacrosMarkDown(strPlistPath) {
    strPlistPath = strPlistPath || sa.pathTo(
      'application support', {
        from: 'user domain'
      }
    ) + "/Keyboard Maestro/Keyboard Maestro Macros.plist";


    var strGroupHdr = '\n#### ',
      strMacroHdr = '- ';


    // Objects ranked by any .name string
    // a --> b --> (-1 | 0 | 1)
    var nameSort = function (a, b) {
      var aName = a.name,
        bName = b.name;
      
      a = aName ? aName.toLowerCase() : '';
      b = bName ? bName.toLowerCase() : '';

      return (a < b) ? -1 : (a > b ? 1 : 0);
    };

    var lstGroups = ObjC.deepUnwrap(
        $.NSDictionary.dictionaryWithContentsOfFile(strPlistPath)
      ).MacroGroups,

      // Unsorted list of Group objects with name and Markdown macro listings
      lstG = lstGroups.reduce(
        function (lstG, g) {

          // Unsorted list of Macro objects with name and MD for any starting comment
          var oMacros = g.Macros,
            lstMacros = oMacros ? oMacros.reduce(
              function (lstM, m) {
                var oActions = m.Actions,
                  dctFirstAction = (oActions && oActions.length) ?
                  oActions[0] : null;

                // MACRO OBJECT with name and any initial comment 
                return lstM.push({
                  name: m.Name,
                  comment: "Comment" === (
                    dctFirstAction ? dctFirstAction.MacroActionType : ""
                  ) ? "\t<b>" + dctFirstAction.Title + "</b>\n\t" + (
                    dctFirstAction.Text.split("\n").join("\n\t") + "\n"
                  ) : ""
                }), lstM;
              }, []
            ) : [];

          // GROUP OBJECT with name and MD for sorted macros
          return (lstG.push({
            name: g.Name,
            macros: (lstMacros.sort(nameSort), lstMacros).reduce(
              function (s, m) {
                return s + strMacroHdr + m.name + '\n' + m.comment + '\n';
              }, ''
            )
          }), lstG);
        }, []
      );

    // Markdown listing of sorted Groups, with their sorted macros
    return (lstG.sort(nameSort), lstG).reduce(
      function (s, g) {
        return s + strGroupHdr + g.name + '\n' + g.macros;
      }, ''
    );
  }


  // Defaults to reading:
  // ~/Library/Application Support/Keyboard Maestro/Keyboard Maestro Macros.plist
  var strReport = kmMacrosMarkDown();

  return (
    sa.setTheClipboardTo(strReport), strReport
  );
}
6 Likes

8 posts were merged into an existing topic: JavaScript / JXA Q&A

Is this macro still working? It doesn’t produce output, and I can’t figure out to debug it. When I copy the content to a .sh file and run in from terminal it produces no output or error.

Hey Matthew,

It appears to be broken.

@ComplexPoint — do you have time to fix this?

-Chris

Thanks – I’ll take a look over the weekend

1 Like

Could you try this ?

Markdown listing of all macros, with any initial comments v2.kmmacros (21.6 KB)

1 Like

Hey Rob,

It appears to be working properly again and is nice and fast.

Thanks.

-Chris

The earlier script worked with an earlier version of comment actions (in which the main field could only be plain text).

Here is a redraft which works with more recent versions of Keyboard Maestro, (thanks to @ccstone for noticing that an update was needed)

Markdown A-Z listing of all Keyboard Maestro Groups and Macros.kmmacros (14 KB)

Expand disclosure triangle for image

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

    // Ver 2.07
    // Grouped and sorted macro listing in MD

    // RobTrew @2021

    // main :: IO ()
    const main = () => {
        const
            headingLevels = 2,
            furtherDepth = delta => 1 < delta ? (
                2 === delta ? (
                    "> "
                ) : ""
            ) : "- ";

        return either(
            alert("MD listing of KM groups and macros")
        )(md => md)(
            bindLR(
                readPlistFileLR(kmPlistPath())
            )(
                dict => Right(
                    mdRendered(headingLevels)(
                        furtherDepth
                    )(
                        groupsMacroNamesAndCommentsTree(
                            "All macros by group"
                        )(
                            dict.MacroGroups
                        )
                    )
                )
            )
        );
    };

// groupsMacroNamesAndCommentsTree :: String ->
// [KM Group] -> Tree String
const groupsMacroNamesAndCommentsTree = title =>
    // A tree of strings with title at the top,
    // KM group names at the next level,
    // and then, by further nesting levels:
    // 1. each macro, with, as a child
    // 2. any initial comment title, and further nested,
    // 3. any body text for that initial comment.
    groups => {
        const localeNameAZ = x =>
            `${x.Name}`.toLocaleLowerCase();

        return Node(title)(
            sortOn(localeNameAZ)(
                // Excluding smart groups
                groups.filter(x => Boolean(x.Macros))
            )
            .map(group =>
                Node(group.Name)(
                    sortOn(localeNameAZ)(group.Macros)
                    .map(macro =>
                        Node(macro.Name)(
                            initialCommentForest(macro)
                        )
                    )
                )
            )
        );
    };


    // -------------------- ROUGH MD ---------------------

    // mdRendered :: Int -> Tree String -> String
    const mdRendered = headingLevels =>
        // Basic Markdown rendering of Tree of strings.
        // Top n levels (headingLevels) use # prefixes, and
        // remaining levels are progressivly indented
        // with prefixes for each additional depth
        // defined by prefixFunction
        prefixFunction => tree => {
            const go = level => x => {
                const
                    delta = level - headingLevels,
                    indent = "    ".repeat(
                        Math.max(0, delta - 1)
                    ),
                    // prefixFunction :: Int -> String
                    pfx = prefixFunction(delta);

                return [
                    0 < delta ? (
                        unlines(
                            lines(`${x.root}`)
                            .map(s => `${indent}${pfx}${s}`)
                        )
                    ) : `\n${"#".repeat(level)} ${x.root}`,
                    ...x.nest.flatMap(go(1 + level))
                ];
            };

            return go(1)(tree).join("\n");
        };


    // ----------------------- KM ------------------------

    // applicationSupportPath :: () -> IO FilePath
    const applicationSupportPath = () =>
        standardAdditions()
        .pathTo("application support", {
            from: "user domain"
        })
        .toString();


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

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

    // initialCommentForest :: KM Macro -> [Tree String]
    const initialCommentForest = macro => {
        // A possibly empty list containing any comment
        // string at the start of the action.
        const actions = macro.Actions;

        return 0 < actions.length ? (() => {
            const firstAction = actions[0];

            return "Comment" === firstAction.MacroActionType ? [
                Node(firstAction.Title)((() => {
                    const body = ObjC.unwrap(
                        $.NSAttributedString.alloc
                        .initWithDataOptionsDocumentAttributesError(
                            firstAction.StyledText,
                            void 0,
                            void 0,
                            void 0
                        ).string
                    );

                    return Boolean(body.trim()) ? (
                        [Node(body)([])]
                    ) : [];
                })())
            ] : [];
        })() : [];
    };

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


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


    // readPlistFileLR :: FilePath -> Either String Object
    const readPlistFileLR = fp =>
        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));
            }
        );


    // standardAdditions :: () -> Application
    const standardAdditions = () =>
        Object.assign(Application.currentApplication(), {
            includeStandardAdditions: true
        });

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

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


    // Node :: a -> [Tree a] -> Tree a
    const Node = v =>
        // Constructor for a Tree node which connects a
        // value of some kind to a list of zero or
        // more child trees.
        xs => ({
            type: "Node",
            root: v,
            nest: xs || []
        });


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


    // Tuple (,) :: a -> b -> (a, b)
    const Tuple = a =>
        b => ({
            type: "Tuple",
            "0": a,
            "1": b,
            length: 2,
            *[Symbol.iterator]() {
                for (const k in this) {
                    if (!isNaN(k)) {
                        yield this[k];
                    }
                }
            }
        });


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


    // comparing :: (a -> b) -> (a -> a -> Ordering)
    const comparing = f =>
        x => y => {
            const
                a = f(x),
                b = f(y);

            return a < b ? -1 : (a > b ? 1 : 0);
        };


    // fst :: (a, b) -> a
    const fst = tpl =>
        // First member of a pair.
        tpl[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);


    // lines :: String -> [String]
    const lines = s =>
        // A list of strings derived from a single
        // string delimited by newline and or CR.
        0 < s.length ? (
            s.split(/[\r\n]+/u)
        ) : [];


    // snd :: (a, b) -> b
    const snd = tpl =>
        // Second member of a pair.
        tpl[1];


    // sortOn :: Ord b => (a -> b) -> [a] -> [a]
    const sortOn = f =>
        // Equivalent to sortBy(comparing(f)), but with f(x)
        // evaluated only once for each x in xs.
        // ('Schwartzian' decorate-sort-undecorate).
        xs => xs.map(
            x => Tuple(f(x))(x)
        )
        .sort(uncurry(comparing(fst)))
        .map(snd);


    // uncurry :: (a -> b -> c) -> ((a, b) -> c)
    const uncurry = f =>
        // A function over a pair, derived
        // from a curried function.
        (...args) => {
            const [x, y] = Boolean(args.length % 2) ? (
                args[0]
            ) : args;

            return f(x)(y);
        };

    // unlines :: [String] -> String
    const unlines = xs =>
        // A single string formed by the intercalation
        // of a list of strings with the newline character.
        xs.join("\n");

    return main();
})();

Just updated above to 2.06 – editing one line to create a version which seems to work here both on a friend's Mojave machine and on my own Big Sur installation.

(I think there may, perhaps, be a subtle difference in the parsing of plists on the two systems)

Let me know.

Hey Rob,

Working on my Mojave system now.

Thanks.

If you care to fool with this anymore it would be nice if the listing included the trigger.

-Chris

2 Likes

Looking at this now, @peternlewis 's osascript interface offers us a trigger.description property, but if we want the speed of reading the .plist directly, we need a translation:

KeyCode -> Modifiers -> Keyboard Layout -> Unicode

The modifiers integer is easily read:

const
    modifiers = trigger.Modifiers,
    modName = [
        [256, "⌘"],
        [512, "⇧"],
        [2048, "⌥"],
        [4096, "^"]
    ].reduce(
        (k, [n, c]) => modifiers & n ? (
            `${k}${c}`
        ) : k,
        ""
    );

but the mapping from KeyCode integer to a particular Unicode character varies with the local keyboard layout.

Has anyone managed get that keyboard-dependent mapping to work in AppleScript / JavaScript terms ?

UCKeyTranslate and NSEvent.charactersByApplyingModifiers: look like possibilities, but I am so far failing to obtain references to them from inside osascript code.

Let me preface this by saying that I wrote the following code over 5 year ago, and I'm not sure whether I completed it or not, although I think I did, but I know I never completed the project it was contained in. With that said, it surely doesn't cover newer triggers that KM introduced since then.

And I'm not claiming this is great code. I was just winging it. Have I waffled enough yet? Oh yeah, this takes an object that has been loaded as a plist. If that doesn't make sense, let me know. Get ready to cringe:

function getKMMacroTriggersSummary(triggers) {

	function keyCodeToString(keyCode) {
		return ["A", "S", "D", "F", "H", "G", "Z", "X", "C", "V", "§", "B", "Q", "W", "E", "R", "Y", "T", "1", "2", "3",
			"4",
			"6", "5", "=", "9", "7", "-", "8", "0", "]", "O", "U", "[", "I", "P", "⏎", "L", "J", "“", "K", ";", "\\", ",",
			"/",
			"N", "M", ".", "⇥", "␣", "`", "⌫", "␊", "⎋", "?", "⌘", "⇧", "⇪", "⌥", "⌃", "⇧", "⌥", "⌃", "fn", "F17",
			"KeypadDecimal", "?", "KeypadMultiply", "?", "KeypadPlus", "?", "KeypadClear", "VolumeUp", "VolumeDown", "Mute",
			"KeypadDivide", "KeypadEnter", "?", "KeypadMinus", "F18", "F19", "KeypadEquals", "Keypad0", "Keypad1", "Keypad2",
			"Keypad3", "Keypad4", "Keypad5", "Keypad6", "Keypad7", "F20", "Keypad8", "Keypad9", "?", "?", "?", "F5", "F6",
			"F7", "F3", "F8", "F9", "?", "F11", "?", "F13", "F16", "F14", "?", "F10", "?", "F12", "?", "F15", "Help/Insert",
			"⇱", "⇞", "⌦", "F4", "⇲", "F2", "⇟", "F1", "←", "→", "↓", "↑"
		][keyCode];
	}

	function keyModifiersToString(modifiers) {
		var result = [];
		if (modifiers & 256) result.push("⌘");
		if (modifiers & 512) result.push("⇧");
		if (modifiers & 1024) result.push("[capslock]");
		if (modifiers & 2048) result.push("⌥");
		if (modifiers & 4096) result.push("^");
		return result.join("");
	}

	function getTriggerRepeatString(repeatTime) {
		if (!repeatTime) return "";

		var i = parseInt(repeatTime);
		if (i >= 60) {
			if (i % (60 * 60) == 0)
				return "repeating every " + (i / (60 * 60)) + " hour(s)";
			if (i % 60 == 0)
				return "repeating every " + (i / 60) + " minute(s)";
		}
		return "repeating every " + repeatTime + " second(s)";
	}

	function getTriggerTimeRangeString(hour, minute) {
		return hour + ":" + minute;
	}

	function getTriggerDaysString(whichDays) {
		if (whichDays == 0) return "on no days";
		if (whichDays == 127) return "every day";
		var result = [];
		if (whichDays & 64) result.push("Sunday");
		if (whichDays & 1) result.push("Monday");
		if (whichDays & 2) result.push("Tuesday");
		if (whichDays & 4) result.push("Wednesday");
		if (whichDays & 8) result.push("Thursday");
		if (whichDays & 16) result.push("Friday");
		if (whichDays & 32) result.push("Saturday");
		return "on " + result.join(", ");
	}

	function getApplicationTriggerActionString(trigger) {
		switch (trigger.FireType2) {
			case "Launch":
				return "Launches";
			case "Quit":
				return "Quits";
			case "WhileRunning":
				return "Is Running " + getTriggerRepeatString(trigger.RepeatTime);
			case "Activate":
				return "Activates";
			case "Deactivate":
				return "Deactivates";
			case "WhileActive":
				return "Is Active " + getTriggerRepeatString(trigger.RepeatTime);
			default:
				return "'" + trigger.FireType2 + "'";
		}
	}

	function getFolderTriggerString(trigger) {
		var observe = {
			Add: "adds an item",
			Remove: "removes an item",
			Both: "adds or removes an item"
		};
		var observeWhen = {
			Immediate: "trigger changes immediately",
			IgnorePartial: "ignore partial files",
			WaitCompletion: "ignore partial or changing files"
		};
		var folder = (trigger.Interest && trigger.Interest.Path) ? trigger.Interest.Path : "";
		if (!folder)
			return "Folder";
		return "The folder '" + folder + "' " + observe[trigger.Interest.Observe] +
			"; " + observeWhen[trigger.Interest.ObserveWhen];
	}

	function getVolumeTriggerVolumeString(trigger) {
		var mounted = { true: "mounted", false: "unmounted" };

		if (trigger.TargetType == "Any")
			return "Any volume";
		return "A volume " + trigger.TargetType.toLowerCase() + " '" + trigger.Name +
			"' is " + mounted[trigger.Mounted];
	}

	function getWirelessTriggerString(trigger) {
		var matchType = {
			NameIs: "the exact name",
			NameContains: "name containing",
			NameMatches: "name matching",
			BSSID: "BSSID"
		};
		var connected = { true: "connected", false: "disconnected" };

		if (trigger.MatchType == "Any")
			return "Any wireless network is " + connected[trigger.Connected];

		return "A wireless network with " + matchType[trigger.MatchType] +
			" '" + trigger.Name + "' is " + connected[trigger.Connected];
	}

	function getMacroTriggerSummary(trigger) {
		var attached = { true: "attached", false: "detached" };

		var triggerType = trigger.MacroTriggerType;
		switch (triggerType) {
			case "HotKey":
				return "HotKey: " + keyModifiersToString(trigger.Modifiers) +
					keyCodeToString(trigger.KeyCode);
			case "MacroPalette":
				return "Global Palette";
			case "StatusMenu":
				return triggerType;
			case "TypedString":
				return "Typed String: '" + trigger.TypedString + "'";
			case "Application":
				var app = trigger.Application;
				var appName = (app && app.Name) ? app.Name : "Any application";
				return appName + " " + getApplicationTriggerActionString(trigger);
			case "Clipboard":
				return "The system clipboard changes";
			case "Time":
				switch (trigger.ExecuteType) {
					case "Launch":
						return "At engine launch";
					case "Login":
						return "At login";
					case "While":
						return "Periodically wile logged in, " +
							getTriggerRepeatString(trigger.RepeatTime) +
							" between " + getTriggerTimeRangeString(trigger.TimeHour, trigger.TimeMinutes) +
							" and " + getTriggerTimeRangeString(trigger.TimeFinishHour, trigger.TimeFinishMinutes) +
							" " + getTriggerDaysString(trigger.WhichDays);
					case "Time":
						return "At " + getTriggerTimeRangeString(trigger.TimeHour, trigger.TimeMinutes) +
							" " + getTriggerDaysString(trigger.WhichDays);
					default:
						return "'" + trigger.ExecuteType + "'";
				}
			case "FocussedWindow":
				switch (trigger.ChangeType) {
					case "FocussedWindowChanges":
						return "The focused window changes";
					case "FocussedWindowTitleChanges":
						return "The focused window title changes";
					case "FocussedWindowsTitleChanged":
						return "The focused window's title changed";
					case "FocussedWindowsFrameChanged":
						return "The focused window's frame changed"
					default:
						"'" + trigger.ChangeType + "'";
				}
			case "Folder":
				return getFolderTriggerString(trigger);
			case "MIDI":
				return "MIDI";
			case "Volume":
				return getVolumeTriggerVolumeString(trigger);
			case "PublicWeb":
				return "The public web entry is executed";
			case "Sleep":
				return "At system sleep";
			case "HID":
				return trigger.ElementName + " " + trigger.FireType;
			case "USBDevice":
				return "USB device " + trigger.TargetType + " '" + trigger.Name + "' is " +
					attached[trigger.Attach];
			case "Wake":
				return "At system wake";
			case "WirelessNetwork":
				return getWirelessTriggerString(trigger);
		}
		return triggerType;
	}

	// getKMMacroTriggersSummary

	if (!triggers || triggers.length == 0) return "";

	var result = [];
	triggers.forEach(function(trigger) {
		result.push(getMacroTriggerSummary(trigger));
	})
	return result.join("\n");
} 

1 Like

Am I right in thinking, though, that your keyCodeToString doesn't allow for variations in keyboard layouts ?

In Anglo-Saxon localities, it's typically true that

KeyCode 0 -> "A"

but that's a local mapping.

with typical FR keyboard layouts for example:

KeyCode 0 -> "Q"

Yes, you're right, I hadn't even thought of that, and now I'm really glad I didn't pursue this any further!

1 Like