1

I’m experimenting with scripting a batch of OmniFocus tasks in JXA and running into some big speed issues. I don't think the problem is specific to OmniFocus or JXA; rather I think this is a more general misunderstanding of how getting objects works - I'm expecting it to work like a single SQL query that loads all objects in memory but instead it seems to do each operation on demand.

Here’s a simple example - let’s get the names of all uncompleted tasks (which are stored in a SQLite DB on the backend):

var tasks = Application('OmniFocus').defaultDocument.flattenedTasks.whose({completed: false})
var totalTasks = tasks.length
for (var i = 0; i < totalTasks; i++) {
    tasks[i].name()
}

[Finished in 46.68s]

Actually getting the list of 900 tasks takes ~7 seconds - already slow - but then looping and reading basic properties takes another 40 seconds, presumably because it's hitting the DB for each one. (Also, tasks doesn't behave like an array - it seems to be recomputed every time it's accessed.)

Is there any way to do this quickly - to read a batch of objects and all their properties into memory at once?

zambezi
  • 1,505
  • 2
  • 11
  • 14

2 Answers2

5

Introduction

With AppleEvents, the IPC technology that JavaScript for Automation (JXA) is built upon, the way you request information from another application is by sending it an "object specifier," which works a little bit like dot notation for accessing object properties, and a little bit like a SQL or GraphQL query.

The receiving application evaluates the object specifier and determines which objects, if any, it refers to. It then returns a value representing the referred-to objects. The returned value may be a list of values, if the referred-to object was a collection of objects. The object specifier may also refer to properties of objects. The values returned may be strings, or numbers, or even new object specifiers.

Object specifiers

An example of a fully-qualified object specifier written in AppleScript is:

a reference to the name of the first window of application "Safari"

In JXA, that same object specifier would be expressed:

Application("Safari").windows[0].name

To send an IPC request to Safari to ask it to evaluate this object specifier and respond with a value, you can invoke the .get() function on an object specifier:

Application("Safari").windows[0].name.get()

As a shorthand for the .get() function, you can invoke the object specifier directly:

Application("Safari").windows[0].name()

A single request is sent to Safari, and a single value (a string in this case) is returned.

In this way, object specifiers work a little bit like dot notation for accessing object properties. But object specifiers are much more powerful than that.

Collections

You can effectively perform maps or comprehensions over collections. In AppleScript this looks like:

get the name of every window of Application "Safari"

In JXA it looks like:

Application("Safari").windows.name.get()

Even though this requests multiple values, it requires only a single request to be sent to Safari, which then iterates over its own windows, collecting the name of each one, and then sends back a single list value containing all of the name strings. No matter how many windows Safari has open, this statement only results in a single request/response.

For-loop anti-pattern

Contrast that approach to the for-loop anti-pattern:

var nameOfEveryWindow = []
var everyWindowSpecifier = Application("Safari").windows
var numberOfWindows = everyWindowSpecifier.length
for (var i = 0; i < numberOfWindows; i++) {
    var windowNameSpecifier = everyWindowSpecifier[i].name
    var windowName = windowNameSpecifier.get()
    nameOfEveryWindow.push(windowName)
}

This approach may take much longer, as it requires length+1 number of requests to get the collection of names.

(Note that the length property of collection object specifiers is handled specially, because collection object specifiers in JXA attempt to behave like native JavaScript Arrays. No .get() invocation is needed (or allowed) on the length property.)

Filtering, and why your code example is slow

The really interesting part of AppleEvents is the so-called "whose clause". This allows you provide criteria with which to filter the objects from which the values will be returned from.

In the code you included in your question, tasks is an object specifier that refers to a collection of objects that have been filtered to only include uncompleted tasks using a whose clause. Note that this is still just reference at this point; until you call .get() on the object specifier, it's just a pointer to something, not the thing itself.

The code you included then implements the for-loop anti-pattern, which is probably why your observed performance is so slow. You are sending length+1 requests to OmniFocus. Each invocation of .name() results in another AppleEvent.

Furthermore, you're asking OmniFocus to re-filter the collection of tasks every time, because the object specifier you're sending each time contains a whose clause.

Try this instead:

var taskNames = Application('OmniFocus').defaultDocument.flattenedTasks.whose({completed: false}).name.get()

This should send a single request to OmniFocus, and return an array of the names of each uncompleted task.

Another approach to try would be to ask OmniFocus to evaluate the "whose clause" once, and return an array of object specifiers:

var taskSpecifiers = Application('OmniFocus').defaultDocument.flattenedTasks.whose({completed: false})()

Iterating over the returned array of object specifies and invoking .name.get() on each one would likely be faster than your original approach.

Answer

While JXA can get arrays of single properties of collections of objects, it appears that due to an oversight on the part of the authors, JXA doesn't support getting all of the properties of all of the objects in a collection.

So, to answer you actual question, with JXA, there is not a way to read a batch of objects and all their properties into memory at once.

That said, AppleScript does support it:

tell app "OmniFocus" to get the properties of every flattened task of default document whose completed is false

With JXA, you have to fall back to the for-loop anti-pattern if you really want all of the properties of the objects, but we can avoid evaluating the whose clause more than once by pulling its evaluation outside of the for loop:

var tasks = []
var taskSpecifiers = Application('OmniFocus').defaultDocument.flattenedTasks.whose({completed: false})()
var totalTasks = taskSpecifiers.length
for (var i = 0; i < totalTasks; i++) {
    tasks[i] = taskSpecifiers[i].properties()
}

Finally, it should be noted that AppleScript also lets you request specific sets of properties:

get the {name, zoomable} of every window of application "Safari"

But there is no way with JXA to send a single request for multiple properties of an object, or collection of objects.

bacongravy
  • 893
  • 8
  • 13
  • Fantastic and thorough answer! Lots of useful info here. Too bad the OP seems to be MIA. @zambezi, if you're out there, you should mark this as the correct answer! – James Apr 11 '19 at 15:50
1

Try something like:

tell app "OmniFocus"
  tell default document
    get name of every flattened task whose completed is false
  end tell
end tell

Apple event IPC is not OOP, it’s RPC + simple first-class relational queries. AppleScript obfuscates this, and JXA not only obfuscates it even worse but cripples it too; but once you learn to see through the faux-OO syntactic nonsense it makes a lot more sense. This and this may give a bit more insight.

[ETA: Omni recently implemented its own embedded JavaScriptCore-based scripting support in its apps; if JS is your thing you might find that a better bet.]

has
  • 167
  • 2
  • Awesome, those links look really helpful and I will study them - been looking for a more high-level understanding of what's going on. To be clear - are those JS examples using nodeautomation rather than JXA? What's the difference between the two? – zambezi Feb 11 '18 at 17:09
  • JXA is riddled with design flaws and missing/broken features, and failed so hard it got the entire MacAutomation team disbanded/sacked. NodeAutomation is descended from appscript, which is the only Apple event bridge other than AppleScript’s (and maybe UserTalk’s) that gets Apple events right. Appscript/NA documentation’s not great, but it’s still way better than anything Apple provides. Neither is developed or supported any more, which is why I recommend sticking to AppleScript. While the entire AE/OSA/AS stack’s doomed now anyway, it should remain usable for a few more years at least. – has Feb 11 '18 at 19:46
  • One more useful link: [AppleScript history and motivation](http://www.cs.utexas.edu/~wcook/Drafts/2006/ashopl.pdf) by its original designer. – has Feb 11 '18 at 19:47
  • Thanks @has this is helpful color. Why do you think the whole stack is doomed / what do you think will replace it? – zambezi Feb 13 '18 at 23:50
  • AE/OSA/AS technology is extremely long in the tooth and hasn’t evolved or aged well (particularly where security is concerned), is non-existent on iOS (which is where all the money is for Apple nowadays), its development team is gone, and it’s been maintenance-moded without a murmur. The real problem though is Apple has 500,000,000 customers(!) and I doubt even 50,000 of those use AppleScript. Growth and interest in AS just seemed to die after 2010. Apple have no immediate need to kill it, but it’s had all its chances to win new users and build new markets, and completely failed to deliver. – has Feb 14 '18 at 19:03
  • I expect Apple will position Workflow as their new consumer-oriented automation UI across ALL Apple platforms. New team, new ideas, new marketing; a new start. Weak sauce compared to the true power of AE/OSA, but what matters is not how good a technology is but whether or not it puts bums on seats. If the Workflow team are smart they’ll have got friendly with the team creating Apple’s new cross-platform UI kit and got themselves some official hooks into that. It’d be crude and limited as hell, but it only has to improve on what 99.99% of customers *currently use* (i.e. nothing!) to win huge. – has Feb 14 '18 at 19:19
  • That said, even if AppleScript only had another 5 years’ practical life left in it, that’s still 5 years of incredibly powerful time-saving automation to be had. And it’s always *possible* (if improbable) that the penny *finally* drops and Apple sees what a massive, unique, and still largely untapped resource it *already* owns, and how it can extract all that value out of it with the modern technologies it has now. (Hint: Apple event-enabled apps were designed to handle complex *user queries* extremely easily. And guess what tech Apple touts now for *sending* user queries? #BetterAskSiri) – has Feb 14 '18 at 19:36
  • Thanks, fascinating insights. In this particular case it doesn't matter because Omni group is already building their own cross-platform AS replacement (https://omni-automation.com/) but it's interesting to hear from someone with more familiarity with the platform. – zambezi Feb 15 '18 at 20:25