Automating DT with JavaScript: Basics

Although AppleScript is prevalent in the DT forum, one can also use JavaScript for automation (JXA) to script it. It might have some advantages in the areas of string and date processing. In the following, I try to collect some patterns and approaches that might help in this context.

Getting the application instance

AppleScript uses tell application ... to set the application context in which method calls are executed. The JavaScript alternative is

const app = Application("DEVONthink 3");
app.includeStandardAdditions = true;

The second line is only necessary if you want to use UI methods like displayAlert. But it does no harm to include it anyway.

Using methods and getting properties

Once you have an application object, you can call its methods:

const selectedRecords = app.selectedRecords();

This returns an array containing the currently selected records from DT. The parenthesis are JXA lingo for get the value(s). If you don’t need the records themselves but for example their UUIDs, you can use

const UUIDs = app.selectedRecords.uuid();

This is a lot faster than iterating over the selectedRecords to get at the UUIDs.

Setting properties

To get a property of (for example) a record, you use

const prop = record.propertyName();

To set a property, you leave out the parenthesis:

record.propertyName = value;

The different approaches are due to the fact that record.propertyName does not refer to the property itself but to an “Object Specifier” in Apple parlance. Appending () calls this object’s get method, whereas using the property name on the left hand side of an assignment refers to the object itself. Or something like that.

Iterating over arrays (aka “lists”)

To iterate over the currently selected records, you can use forEach:

const recs = app.selectedRecords();
recs.forEach(r => {
  /* do something with r */
})

For example, to add a stylesheet to the beginning of all currently selected markdown files, you’d use

const style = 'h1: { color: blue; }';
const recs = app.selectedRecords();
recs.forEach(r => {
  /* get the text of the MD document */
  if (r.type() === "markdown") {
	const pt = r.plainText();
	r.plainText = `<style>${style}</style>
${pt}`;

Note that it is not necessary to write the record back or anything: Simply setting its plainText property will change the content. Therefore you might want to experiment with a copy of your records before running your script on the whole database.

Iteration for non-arrays

As you’ve seen, forEach iterates over all elements of an array returning the current element in each iteration. Sometimes you’re not dealing with arrays but e.g. a Set or a Map. Those objects do not provide the forEach method, but for … of works well:

const recs = app.selectedRecords();
for (let r of recs) {
/* do something with r */
}

Getting the database

JXA allows to access objects directly by name:

const db = app.databases["MyDatabase"];

gives you the Object Specifier of the database called “MyDatabase”. To print all groups in a database to the console, you’d use

let db = app.databases["MyDatabase"];
db.records().forEach( r =>  {
if (r.type() === "group") {
    console.log(r.name());
  }
})

Alternatively, you should be able to use the whose methode to get at the groups and then append the name property to the query to get an array of names:

/* THIS DOES NOT WORK! */
let db = app.databases["MyDatabase"];
let names = db.records.whose({type: "group"}).name();
console.log(names);

Unfortunately, using type with whose does NOT work currently. I suppose there’s something going on under the hood in DTs scripting implementation, but who knows…

Creating groups and records

To create a group in DT, you use createLocation. If the group exists already, it will simply return it. Otherwise, it will create the group and return it. So it is not possible to create a second group with the same name in the same database using createLocation. But that’s probably not something you’d want to do anyway.

const db = app.databases["MyDatabase"]
const dbGroup =  app.createLocation("/path/to/group", {in: db});

This will create “path” as top level group in “MyDatabases”, “to” as subgroup in “path” and finally “group” in “to”. Of course, if this hierarchy exists already, createLocation will simply return the Object Identifier of “group”.

MyDatabase
|
--- path
    |
	--- to
	    |
		--- group

Once you have the Object Specifier of a group, you can create a record in this group:

const myRecord = app.createRecordWith(
  {type: "markdown", 
   name: "My Markdown Document"}, 
    {in: dbGroup});

Note that some record types require you to enter more properties in the first object, for example a richText. So some experimentation might be in order.

Handling sheets in DT

DT can handle classic CSV documents (comma separated values) you can export from most any spreadsheet program. It also offers its own flavour in the form of “TSV” documents (tab separated values). To read and write those records, you have to access their cells property: const cells = record.cells();. This call returns an array of rows, each row being an array of cells. So to get at the second column in every row, you’d use

const cells = record.cells();
cells.forEach( r => {
  console.log(r[1]); /* print second element of row */
})

Note that in JavaScript array indices start with 0!

Now suppose you wanted to find the document where the 4th cell of the 3rd row contains the word “Bingo”. So you’d first get all sheets from DT and then iterate over them to check for this word in the appropriate place:

const recs = app.search("type: sheet");
recs.forEach(r => {
  if (r.cells()[2][3] === "Bingo") {
	console.log(`Found in ${r.name()} in database ${r.database.name()}`)
  }
}

Working with Markdown files

Questions about modifying markdown files are fairly frequent in the forum. In order to do so, you must access the plainText property of the record(s):
const pt = rec.plainText(); To append to it, use rec.plainText = pt + '\n\nNew text;. There’s no need to write back the document, setting its plainText property already changes and persists the content.

Suppose you have links in your Markdown document that refer to the domain example.com which you want to change to otherdomain.org:

const oldDomain = 'example.com';
/*
  Build a regular expression to find all links to documents on example.com
  Opening parenthesis '\\('
  followed by anything up to oldDomain '(.*?)${olddomain}
	   "anything up to" is captured in group 1 (i.e. the first pair of parentesis
*/
const RE = new RegExp(`\\((.*?)${oldDomain}`, 'g'); 
const newDomain = 'otherdomain.org';
var pt = rec.plainText();
/*
  Replace everything matched by the regular expression with
	Opening parenthesis '('
	first matching group '$1' (i.e. the part _before example.com_
	newDomain
*/
rec.plainText = pt.replaceAll(RE, `($1${newDomain}`);

There’s a more complex and fully documented example on adding markdown links to DT documents available in the forum.

11 Likes

Very nice post. Thanks for the time and effort. :slight_smile:

Thanks for the nice words. It was my pleasure.

A not-insignificant portion of education is the process of demystifying concepts and technology. Posts like this go a long way toward planting a seed that someone, like oh I don’t know, ME, can read and digest and slowly get around to giving javascript a shot.
If there was a Nobel Prize for voluntary forum contributions I’d throw your name in there.

1 Like