Working with Objects

Working with Objects

All objects in JXA can have properties, methods, and elements. Unfortunately, Apple’s terminology is not clear and seems a bit weird nowadays. In Script Editor, you will see the term “Element” for (usually) lists of objects contained in another object. In other words, an object may contain several of the same elements, which are grouped together in a kind of array (see below). “Property” is used for attributes that an only occur once in an object. Properties can be of any type like boolean, string, number, array, and also a single object. So if your obj object provides properties called name and color, you might be tempted to write obj.name to get the name or app.color to get the color.

This will not work. Object properties cannot be read by simply referring to them because this only returns an “Object Specifier”, kind of an (mostly) opaque pointer.

Reading properties and elements#

To read a property or get elements, you have to use function syntax. This converts the Object Specifier returned by Apple’s scripting framework to a JavaScript object or primitive. You’ve already seen this in the first JXA example: app.accounts() returned the list of mail accounts as a JavaScript array. Basically, using a property like a function calls the Object Specifier’s get method. So writing obj.name() is the same as obj.name.get().

This cannot be stressed enough: As long as you do not call an object or a property as a function by appending (), you’ll be working with an Object Specifier. That implies two things.

  • You can chain Object Specifiers like so app.accounts.name() or app.documents[0].sheets.tables.name() to get a JavaScript array of all account and table names.
  • When you call an Object Specifier as a function, it returns a JavaScript object or primitive.

So accounts.name is a (list of) Object Specifier, accounts.name() and accounts.name.get() is a JavaScript array of strings. Calling any array method on the first will give an error as will calling a JXA method on the second.

Alternatively, you can use object["name"]() to get the value of the object’s property “name”. This might come in handy if you want to loop over a list of property names.

When you see the term “list”, it means the same as array in JavaScript. The function libraries shown in Script Editor always use the term “lists”, so it is easier to use the same word here.

Writing properties and elements#

On the other hand, to write a property, you simply assign to it like so app.theProperty = value;. This is also true with property lists: You can’t use push or other array methods to change list properties of an object. Instead, assign to the property, for example like this object.list = [...newList, ...object.list()], using the spread syntax to build an array from two other ones.

However, you can not assign to an element (which is a list of objects) this way. They can be added to by using the push method. You can find an example for that in the chapter on Mail.

Getting list (array) elements#

In Apple’s scripting environment, objects in a list can often be addressed either by name or by index. That’s also true for JXA. The following example returns the mail account named “boo” as an Object Specifier:

(() => {
  const app = Application("Mail");
  const account = app.accounts["boo"];
  console.log(`${account.name()}`);
})()

This approach works whenever the objects in a list have a name property. And as you can see, you do not need to dereference the Object Specifier by appending () to it, indexing it with the usual JavaScript syntax works just fine.

Alternatively, you can leave out the square brackets if the name does not contain special characters like spaces:

(() => {
  const app = Application("Mail");
  const account = app.accounts.boo;
  console.log(`${account.name()}`);
})()

Similarly, you can use a number to get an element of the list, again without dereferencing the list with () first.

(() => {
  const app = Application("Mail");
  const account = app.accounts[0];
  console.log(`${account.name()}`);
})()

Leaving out the square brackets in this case will not work, though.

Although it looks as if you were working with an array, you’re not! Therefore, the following code to get all mail account names will not work:

// THIS CODE WILL NOT WORK
  const app = Application("Mail");
  const acc = app.accounts;
  acc.forEach(a => console.log(a.name()));

but this code will:

  const app = Application("Mail");
  /* Use a function call! */
  const acc = app.accounts(); 
  acc.forEach(a => console.log(a.name()));
Elements (lists of objects) are not JavaScript arrays, although you can access their individual elements as if they were. If you want to use JavaScript’s array methods like forEach, map and so on, you must first convert the element with (). The only exception to this rule is the length property: it is available for all lists of Object Specifiers in JXA. So app.accounts.length will return the number of elements in app.accounts as will app.account().length

Filtering lists (arrays) with whose#

Apple invented the whose method to filter lists of Object Specifiers. It is loosely following the approach of SQL by describing the data you want to have. Let’s assume that you want to know which of your mail accounts contain the current user’s name in their name ( line 5):

(() => {
  const app = Application("Mail");
  const user = Application("System Events").currentUser.name();
  const userAccounts = app.accounts.whose(
      {userName: {_contains: user}})();
  console.log(`${userAccounts.map( x => x.name())}`);
})()

Here, the whose methods returns those accounts where the userName property contains the short version of the user’s name and prints out the account names on the command line.

You can use whose with the logical predicates and, or, and not. For example, to get a list of all Reminders completed yesterday:

(() => {
  /* Build 'yesterday' as a Date object */
  const now = new Date();
  const yesterday = new Date(now.setDate(now.getDate() - 1));
  yesterday.setHours(0);
  yesterday.setMinutes(0);
  yesterday.setSeconds(0);
  const app = Application("Reminders");
  const done = app.reminders.whose({_and: [
    {completed: true},
    {completionDate: {'>': yesterday}}
  ]}).name();
})()

As you can see, _and expects an array as its value containing the conditions. The same is true for _or, whereas _not needs a single condition. The section on System Events contains another practical example on the usage of whose.

Note that whose will always return a list of Object Specifiers, even if it found only one element. Again, you must call the result as a function to convert it to a JavaScript array.

Filtering for special properties#

While it is quite straightforward to filter for text, numerical and boolean properties, using whose requires some calisthenics if enumerations are concerned. If, for example, you wanted to find those mail accounts that use password authentication, you might want to try this code:

(() => {
  // THIS CODE WILL NOT RUN
  const app = Application("Mail");
  const pwAccounts = app.accounts.whose(
      {authentication: "password"})();
})()
This will fail miserably in Script Editor with the obscure error message: “Types cannot be converted.”

The reason is that Apple botched something behind the scenes. Internally, the whose above is converted to

app.accounts.whose(
    {_match: [ObjectSpecifier().authentication, "password"]})

and if you use it exactly like that, you’ll get what you want:

(() => {
  const app = Application("Mail");
  app.includeStandardAdditions = true;
  const pwAccounts = app.accounts.whose(
      {_match: [ObjectSpecifier().authentication, "password"]})()
  console.log(`${pwAccounts.map( x => x.name())}`);
})()

So whenever whose throws the error “Types cannot be converted”, you might want to try the same approach.

Why you want to use whose#

Simply, because it is blazingly fast. Lightning fast. Actually, it runs in near constant time – regardless of the size of the array, whose needs the same time to find the elements you need.

In a private benchmark with the program “DEVONthink 3 Pro” on macOS 11, whose needed about 0.3 ms to search an array with 117 elements as well as for one of 511. Every other method scaled linearly (as is to be expected). So they ran about 5 times longer for 511 records than for 117.

More numbers: whose needed about 0.00064 milliseconds per record, the next best method (in JavaScript) required 0.1milliseconds. After that, we’re at 2ms per record. So for 500 records, you’d need 1 second – and only 0.32ms with whose.

The sample code follows below, click on the triangle to expand it.

Benchmark comparing whose with other methods
(() => {
  const app = Application("DEVONthink 3");
  app.includeStandardAdditions = true;
  /*
  * get all markdown records from selection
  */
  const iterations = 100; // Keep it low, otherwise it takes forever
  const count = app.selectedRecords.length;
  
  timeIt(function() {
    app.selectedRecords.whose({_match: [ObjectSpecifier().type, "markdown"]})}, 
      iterations, count);
  
  timeIt(function(){
    function checkType(r) {
      r.type() === "markdown"
    };

  app.selectedRecords().filter(checkType); }, 
      iterations, count);


  timeIt(function() {
    const theRecs = app.selectedRecords();
    const MDrecords = theRecs.filter(r => r.type() === "markdown");
      }, iterations, count);

  timeIt(function() {
        const theRecs = app.selectedRecords();
        const MDrecords = [];
        for(let i = 0; i < theRecs.length; i++) {
           if (theRecs[i].type() === "markdown") {
             MDrecords.push(theRecs[i]);
          }
      }}, iterations, count);

  timeIt(function() {
        const theRecs = app.selectedRecords();
        const theTypes =app.selectedRecords.type();
        const MDrecords = [];
        for(let i = 0; i < theRecs.length; i++) {
           if (theTypes[i] === "markdown") {
             MDrecords.push(theRecs[i]);
          }
      }}, iterations, count);

  function timeIt(func, iterations, total) {
      let start = Date.now();
          for (let i = 0; i < iterations; i++) {
          func();
      }
      let end = Date.now();
      console.log(func.toString());
      console.log(`elapsed: ${end-start}ms, ${(end-start)/iterations} ms per iteration ${total} records`);
}
})()