All objects in JXA can have properties, methods and elements.
Unfortunately, Apple’s terminology is not very 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 can not be read by simply referring to them, because this only returns an “Object Specifier”, kind of an (mostly) opaque pointer.
Reading properties and elements #
In order to read a property or get at elements, you have to use function syntax.
This converts the Object Specifier returned by Apples 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()
orapp.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()
andaccounts.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 likeforEach
,map
and so on, you must first convert the element with()
. The only exception to this rule is thelength
property: it is available for all lists of Object Specifiers in JXA. Soapp.accounts.length
will return the number of elements inapp.accounts
as willapp.account().length
Filtering lists (arrays) #
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. There’s a more practical example for the usage of whose
in the section on System Events
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 `
(() => {
// 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.1
milliseconds. 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, just 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`);
}
})()