Mimsy Were the Borogoves

Hacks: Articles about programming in Python, Perl, Swift, BASIC, and whatever else I happen to feel like hacking at.

JXA and AppleScript compared via HyperCard

Jerry Stratton, August 9, 2023

Superhero stack opening card: Main “control panel” card for the Men & Supermen superhero character sheet HyperCard stack.; Men & Supermen; HyperCard

All this for a script that will be used no more than a handful of times, to restore long-replaced data from the nineties. It’s time to quote Douglas Adams again.

In A HyperCard Time Machine I wrote that I also seriously considered writing the script as a JavaScript (JXA) app. In fact, I did write the script as a JavaScript app. It was a toss-up to the end which version I was going to use, so they’re both fully-working versions.

One advantage the JXA version has is not requiring old-school, BASIC-style manipulation of strings to extract the current card number and the total card count from the window title. Instead of grabbing the location of the final slash, which required serious gymnastics in AppleScript, the JavaScript solution can use a regular expression that is both shorter and more reliable:

[toggle code]

  • titleRegex = new RegExp("^(.+[^ ])  *([1-9][0-9]*) *\/ *([1-9][0-9]*)$");
  • titleParts = titleRegex.exec(title);
  • filename = titleParts[1];
  • currentCard = titleParts[2];
  • totalCards = titleParts[3];

JXA also doesn’t use tell blocks. It places the application in a variable, and the things we want to tell it to do can be handled at any point without worrying about how a tell block affects the syntax. At the beginning of the app, I did:

  • app = Application.currentApplication();
  • app.includeStandardAdditions = true;
  • system = Application("System Events");

This gave me the current application and System Events. The current application is necessary for dialog with the user, which is why I’ve also included the Standard Additions on it. The Standard Additions include Display Dialog and Choose Folder.

Because JXA doesn’t use tell blocks, it is easier to place the request for the destination folder after the check to see if HyperCardPreview is running and has at least one open window.1

Where the JXA solution falls down, and, ultimately why I decided not to use it, is that it contains some very odd behavior. The lesser of them is that there is no equivalent to Quoted Form Of in JXA. That’s not a killer, because a functional equivalent can easily be added.

[toggle code]

  • function quotedFormOf(string) {
    • //Not sure why replacing with multiple apostphrophes is necessary
    • //But it matches what is necessary on the command line in Terminal
    • return "'" + string.replaceAll("'", "'\\''") + "'";
  • }

Someone with more knowledge of the command line can probably tell me why double-apostrophes are necessary to properly escape internal apostrophes.

Interestingly, JXA also doesn’t contain a POSIX path of equivalent. It doesn’t need it. It appears to use the POSIX path by default. Simply converting a path to a string results in the path being a POSIX path.

  • destination = app.chooseFolder({withPrompt:"Where do you want to save the screen shots?"});
  • destination = destination.toString();

The above will provide the path to the folder using slashes rather than drive name and colons. This is likely to cause some problems in other cases, but in this case that’s what I need anyway, since the actual screenshot is handled by /usr/sbin/screenshot.

More weirdly, this fails:

  • stackPreview.menuBars[0].menuBarItems.byName('Browse').menuItems.byName("Go to Next Card").click();

That’s even though this does not fail:

  • stackPreview.menuBars[0].menuBarItems.byName('Browse').click();

That will successfully cause HyperCardPreview’s Browse menu to drop down. The solution is to use the apparently-not-equivalent syntax of:

  • stackPreview.menuBars[0].menus.byName('Browse').menuItems.byName("Go to Next Card").click();

That’s how I referred to the menus anyway in the AppleScript version. This is a weird discrepancy, but not a critical one. The same discrepancy appears in AppleScript. This will pull down the menu:

[toggle code]

  • tell application "System Events"
    • tell application process "HyperCardPreview"
      • set frontmost to true
      • click menu bar item "Browse" of menu bar 1
    • end tell

But click menu item "Go to Next Card" of menu bar item "Browse" of menu bar 1 “Can’t get menu item "Go to Next Card"”. The similar syntax click menu item "Go to Next Card" of menu "Browse" of menu bar 1 is required, just as in JavaScript.

I first attempted to do the screenshots using System Events to invoke the screenshot with CMD-SHIFT-4 (or CMD-$). I abandoned this approach because the resulting files didn’t have useful names and because they were all deposited on the Desktop. But the technique does show how sometimes the JavaScript and AppleScript versions coincide nearly exactly.

The AppleScript version was:

[toggle code]

  • --take the screenshot
  • tell application "System Events"
    • keystroke "$" using {command down}
    • delay 0.1
    • keystroke " "
    • keystroke return
  • end tell

It’s simple enough to understand, and the JavaScript version is just as simple:2

  • system = Application("System Events");
  • //take the screenshot
  • system.keystroke("$", {using: ['command down']});
  • delay(0.1);
  • system.keystroke(" ");
  • system.keystroke("\r");

Sending keyCode 13 or keyCode 10 at the end fails, but using the Unix equivalent of AppleScript’s return variable works fine.

I suspect that some people will see the AppleScript version as cleaner, and some the JavaScript version. If you decide you prefer the JavaScript solution, it, like the AppleScript version, needs to be saved as an application, and for the same reason: you need to grant it permission to control applications on your computer and do screen recording without annoying messages. Every time you update the app, manually remove it from both the Accessibility list and the Screen Recording list under the Privacy pane of the Security & Privacy settings, then re-add it by dragging the app in to each list. See the parent post for more details.

This is probably thinking far too much, for a script that is rarely going to be used—after all, I’m not creating new HyperCard stacks nowadays—about whether or not to switch out the AppleScript version for the JXA version. But it was interesting. There are advantages to each; ultimately I prefer the readability of the AppleScript version, despite the more complicated string manipulation in AppleScript and the use of variables in place of tell blocks in JXA.

The main reason for that is likely that this app is more about communication and less about data manipulation. AppleScript excels at the former.

Here is the full code:

[toggle code]

  • // do a screen capture of all cards in a Hypercard Stack
  • // Jerry Stratton, astoundingscripts.com
  • app = Application.currentApplication();
  • app.includeStandardAdditions = true;
  • system = Application("System Events");
  • titleRegex = new RegExp("^(.+[^ ])  *([1-9][0-9]*) *\/ *([1-9][0-9]*)$");
  • //get app by search so that a failure does not stop the script
  • stackPreview = system.applicationProcesses.whose({name:"HyperCardPreview"});
  • if (stackPreview().length < 1 || stackPreview[0].windows().length < 1) {
    • app.displayDialog("Open and size the stack first.", {buttons: ["Cancel"]});
  • } else {
    • //choose folder to save into
    • destination = app.chooseFolder({withPrompt:"Where do you want to save the screen shots?"});
    • destination = destination.toString();
    • stackPreview = stackPreview[0];
    • stackPreview.frontmost = true;
    • //determine the rect to copy
    • stackWindow = stackPreview.windows()[0];
    • position = stackWindow.position();
    • size = stackWindow.size();
    • rectString = position[0] + ',' + position[1] + ',' + size[0] + ',' + size[1]
    • command = "/usr/sbin/screencapture -R " + rectString + " ";
    • //loop through each card and save as a screenshot
    • goToNextCard = stackPreview.menuBars[0].menus.byName('Browse').menuItems.byName("Go to Next Card");
    • while (true) {
      • //create filename using the current card number from the window title
      • //windows() must be run on each interation, or it fails when going to the next card
      • stackWindow = stackPreview.windows()[0];
      • title = stackWindow.title();
      • titleParts = titleRegex.exec(title);
      • filename = titleParts[1];
      • currentCard = titleParts[2];
      • totalCards = titleParts[3];
      • filename = destination + '/' + currentCard + ' ' + filename + '.png';
      • app.doShellScript(command + quotedFormOf(filename));
      • if (currentCard == totalCards) {
        • break;
      • }
      • goToNextCard.click();
    • }
  • }
  • function quotedFormOf(string) {
    • //Not sure why replacing with multiple apostphrophes is necessary
    • //But it matches what is necessary on the command line in Terminal
    • return "'" + string.replaceAll("'", "'\\''") + "'";
  • }

In response to A HyperCard Time Machine: Use AppleScript and HyperCardPreview to archive a screenshot of every card in a HyperCard stack.

  1. Since I use this script under HyperCardPreview’s script menu, checking to see if the app is running is mostly superfluous: the script is only available if the app is running. It does help for testing it under Script Editor, and that’s about it.

  2. In both versions the script also has to correctly position the mouse; as I mentioned in the previous post, I used cliclick for that. I had to add 4 to both the x and y values of HyperCardPreview’s window position to move it down and over from the top left. Otherwise, screenshot grabbed whatever was under the HyperCardPreview window. This is likely because windows in macOS have rounded corners.